@koi-br/ocr-web-sdk 1.0.26 → 1.0.28

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.
@@ -10,30 +10,20 @@
10
10
  <slot name="left-top"></slot>
11
11
  </div>
12
12
  <div class="toolbar-group">
13
- <span class="scale-text">
14
- {{ Math.round(scale * 100) }}%
15
- </span>
13
+ <span class="scale-text"> {{ Math.round(scale * 100) }}% </span>
16
14
  <div class="toolbar-divider"></div>
17
- <ATooltip mini position="bottom" content="重置">
18
- <AButton
19
- size="small"
20
- type="outline"
21
- @click="reset"
22
- >
23
- <RefreshCcw :size="16" />
24
- </AButton>
25
- </ATooltip>
26
15
  <ATooltip
27
- v-if="originalId"
16
+ v-if="showResetButton"
28
17
  mini
29
18
  position="bottom"
30
- content="查看原图"
19
+ content="重置"
31
20
  >
32
- <AButton
33
- size="small"
34
- type="outline"
35
- @click="original"
36
- >
21
+ <AButton size="small" type="outline" @click="reset">
22
+ <RefreshCcw :size="16" />
23
+ </AButton>
24
+ </ATooltip>
25
+ <ATooltip v-if="originalId" mini position="bottom" content="查看原图">
26
+ <AButton size="small" type="outline" @click="original">
37
27
  <Maximize2 :size="16" />
38
28
  </AButton>
39
29
  </ATooltip>
@@ -58,16 +48,12 @@
58
48
  </AButton>
59
49
  </ATooltip>
60
50
  <ATooltip
61
- v-if="isDownload"
51
+ v-if="showDownloadButton"
62
52
  mini
63
53
  position="bottom"
64
54
  content="下载"
65
55
  >
66
- <AButton
67
- size="small"
68
- type="outline"
69
- @click="emit('download')"
70
- >
56
+ <AButton size="small" type="outline" @click="emit('download')">
71
57
  <Download :size="16" />
72
58
  </AButton>
73
59
  </ATooltip>
@@ -78,11 +64,7 @@
78
64
  position="bottom"
79
65
  content="向左旋转"
80
66
  >
81
- <AButton
82
- size="small"
83
- type="outline"
84
- @click="rotateImage('left')"
85
- >
67
+ <AButton size="small" type="outline" @click="rotateImage('left')">
86
68
  <RotateCw :size="16" />
87
69
  </AButton>
88
70
  </ATooltip>
@@ -92,11 +74,7 @@
92
74
  position="bottom"
93
75
  content="向右旋转"
94
76
  >
95
- <AButton
96
- size="small"
97
- type="outline"
98
- @click="rotateImage('right')"
99
- >
77
+ <AButton size="small" type="outline" @click="rotateImage('right')">
100
78
  <RotateCcw :size="16" />
101
79
  </AButton>
102
80
  </ATooltip>
@@ -113,13 +91,12 @@
113
91
  <ChevronLeft :size="16" />
114
92
  </AButton>
115
93
  </ATooltip>
116
- <span class="page-info">
117
- {{ currentPage }} / {{ totalPages }}
118
- </span>
94
+ <span class="page-info"> {{ currentPage }} / {{ totalPages }} </span>
119
95
  <ATooltip mini position="bottom" content="下一页">
120
96
  <AButton
121
97
  size="small"
122
98
  type="outline"
99
+ style="padding-right: 0px;"
123
100
  :disabled="currentPage >= totalPages"
124
101
  @click="goToNextPage"
125
102
  >
@@ -139,38 +116,46 @@
139
116
  @mouseleave="stopPan"
140
117
  @scroll="handleScroll"
141
118
  >
142
- <div
143
- class="image-wrapper-container"
144
- :style="containerStyle"
145
- >
119
+ <div class="image-wrapper-container" :style="containerStyle">
120
+ <!-- 渲染所有图片页面 -->
146
121
  <div
147
- class="image-wrapper"
148
- :style="{
149
- transform: `translate(${position.x}px, ${position.y}px) rotate(${rotation}deg) scale(${scale})`,
150
- transformOrigin: 'top center'
151
- }"
122
+ v-for="(imageUrl, pageIndex) in imageUrls"
123
+ :key="pageIndex"
124
+ :data-page-number="pageIndex + 1"
125
+ class="image-page-container"
152
126
  >
153
- <img
154
- v-if="currentImageUrl"
155
- ref="imageRef"
156
- :src="currentImageUrl"
157
- alt="预览图片"
158
- :style="{
159
- cursor: isPanning ? 'grabbing' : 'grab',
160
- display: 'block',
161
- pointerEvents: currentPageBlocksData.value && currentPageBlocksData.value.length > 0 ? 'none' : 'auto'
162
- }"
163
- @contextmenu.prevent
164
- @mousedown="handleImageMouseDown"
165
- @load="onImageLoad"
166
- />
167
-
168
- <!-- 文本图层(用于文本块选择和定位) -->
169
- <div
170
- v-if="currentPageBlocksData && currentPageBlocksData.length > 0"
171
- ref="textLayerRef"
172
- class="text-layer"
173
- ></div>
127
+ <div
128
+ class="image-wrapper"
129
+ :style="{
130
+ transform: `translate(${position.x}px, ${position.y}px) rotate(${rotation}deg) scale(${scale})`,
131
+ transformOrigin: 'top center',
132
+ }"
133
+ >
134
+ <img
135
+ :ref="(el) => setImageRef(el, pageIndex + 1)"
136
+ :src="imageUrl"
137
+ :alt="`预览图片 ${pageIndex + 1}`"
138
+ :style="{
139
+ cursor: isPanning ? 'grabbing' : 'grab',
140
+ display: 'block',
141
+ pointerEvents:
142
+ getPageBlocksData(pageIndex + 1) &&
143
+ getPageBlocksData(pageIndex + 1).length > 0
144
+ ? 'none'
145
+ : 'auto',
146
+ }"
147
+ @contextmenu.prevent
148
+ @mousedown="handleImageMouseDown"
149
+ @load="(e) => onImageLoad(e, pageIndex + 1)"
150
+ />
151
+
152
+ <!-- 文本图层(用于文本块选择和定位) -->
153
+ <div
154
+ v-if="getPageBlocksData(pageIndex + 1) && getPageBlocksData(pageIndex + 1).length > 0"
155
+ :ref="(el) => setTextLayerRef(el, pageIndex + 1)"
156
+ class="text-layer"
157
+ ></div>
158
+ </div>
174
159
  </div>
175
160
  </div>
176
161
 
@@ -221,7 +206,12 @@
221
206
  <AButton size="small" type="outline" @click="closeAnnotationInput">
222
207
  取消
223
208
  </AButton>
224
- <AButton size="small" type="primary" @click="saveAnnotation" :disabled="!annotationInput.trim()">
209
+ <AButton
210
+ size="small"
211
+ type="primary"
212
+ @click="saveAnnotation"
213
+ :disabled="!annotationInput.trim()"
214
+ >
225
215
  保存
226
216
  </AButton>
227
217
  </div>
@@ -231,9 +221,35 @@
231
221
  </template>
232
222
 
233
223
  <script setup lang="ts">
234
- import { ref, watch, nextTick, onMounted, onBeforeUnmount, computed } from 'vue';
235
- import { ZoomIn, ZoomOut, RefreshCcw, RotateCw, RotateCcw, Download, Maximize2, Copy, ChevronLeft, ChevronRight, MessageSquare, X } from 'lucide-vue-next';
236
- import { Button as AButton, Tooltip as ATooltip, Message, Input, Textarea } from '@arco-design/web-vue';
224
+ import {
225
+ ref,
226
+ watch,
227
+ nextTick,
228
+ onMounted,
229
+ onBeforeUnmount,
230
+ computed,
231
+ } from "vue";
232
+ import {
233
+ ZoomIn,
234
+ ZoomOut,
235
+ RefreshCcw,
236
+ RotateCw,
237
+ RotateCcw,
238
+ Download,
239
+ Maximize2,
240
+ Copy,
241
+ ChevronLeft,
242
+ ChevronRight,
243
+ MessageSquare,
244
+ X,
245
+ } from "lucide-vue-next";
246
+ import {
247
+ Button as AButton,
248
+ Tooltip as ATooltip,
249
+ Message,
250
+ Input,
251
+ Textarea,
252
+ } from "@arco-design/web-vue";
237
253
 
238
254
  /**
239
255
  * 文本块数据结构
@@ -260,71 +276,86 @@ const props = defineProps({
260
276
  // 支持单个URL(向后兼容)或URL数组
261
277
  url: {
262
278
  type: [String, Array] as () => string | string[],
263
- required: true
279
+ required: true,
264
280
  },
265
281
  minScale: {
266
282
  type: Number,
267
- default: 0.1
283
+ default: 0.1,
268
284
  },
269
285
  maxScale: {
270
286
  type: Number,
271
- default: 5
287
+ default: 5,
272
288
  },
273
289
  clickStep: {
274
290
  type: Number,
275
- default: 0.25
291
+ default: 0.25,
276
292
  },
277
293
  wheelStep: {
278
294
  type: Number,
279
- default: 0.1
295
+ default: 0.1,
280
296
  },
281
297
  originalId: {
282
298
  type: String,
283
- default: ''
299
+ default: "",
284
300
  },
285
301
  isDownload: {
286
302
  type: Boolean,
287
- default: false
303
+ default: false,
288
304
  },
289
305
  blocksData: {
290
306
  type: Array as () => BlockInfo[],
291
- default: () => []
307
+ default: () => [],
292
308
  },
293
309
  enableWheelZoom: {
294
310
  type: Boolean,
295
- default: true // 默认启用滚轮缩放,保持向后兼容
311
+ default: true, // 默认启用滚轮缩放,保持向后兼容
296
312
  },
297
313
  // 批注数据(从外部传入,用于显示已有批注)
298
314
  annotations: {
299
315
  type: Array as () => AnnotationInfo[],
300
- default: () => []
316
+ default: () => [],
301
317
  },
302
318
  // 是否在初始加载时自适应宽度
303
319
  autoFitWidth: {
304
320
  type: Boolean,
305
- default: true
321
+ default: true,
306
322
  },
307
323
  // 是否启用滚动翻页
308
324
  enableScrollPaging: {
309
325
  type: Boolean,
310
- default: true
326
+ default: true,
311
327
  },
312
328
  // 是否显示旋转按钮
313
329
  showRotateButtons: {
314
330
  type: Boolean,
315
- default: true
316
- }
331
+ default: true,
332
+ },
333
+ // 是否显示重置按钮
334
+ showResetButton: {
335
+ type: Boolean,
336
+ default: true,
337
+ },
338
+ // 是否显示下载按钮
339
+ showDownloadButton: {
340
+ type: Boolean,
341
+ default: true, // 默认不显示,保持向后兼容(isDownload 的默认值)
342
+ },
317
343
  });
318
344
 
319
345
  const emit = defineEmits<{
320
- 'original': [];
321
- 'download': [];
322
- 'load': [data: { width: number; height: number; page?: number; totalPages?: number }];
323
- 'position-jump': [bbox: [number, number, number, number], type: 'block' | 'seal'];
324
- 'page-change': [page: number, totalPages: number];
325
- 'annotation-add': [annotation: AnnotationInfo]; // 添加批注事件
326
- 'annotation-update': [annotation: AnnotationInfo]; // 更新批注事件
327
- 'annotation-delete': [annotationId: string]; // 删除批注事件
346
+ original: [];
347
+ download: [];
348
+ load: [
349
+ data: { width: number; height: number; page?: number; totalPages?: number }
350
+ ];
351
+ "position-jump": [
352
+ bbox: [number, number, number, number],
353
+ type: "block" | "seal"
354
+ ];
355
+ "page-change": [page: number, totalPages: number];
356
+ "annotation-add": [annotation: AnnotationInfo]; // 添加批注事件
357
+ "annotation-update": [annotation: AnnotationInfo]; // 更新批注事件
358
+ "annotation-delete": [annotationId: string]; // 删除批注事件
328
359
  }>();
329
360
 
330
361
  // 图片URL数组和当前页码
@@ -340,7 +371,7 @@ const totalPages = computed(() => imageUrls.value.length);
340
371
 
341
372
  // 当前显示的图片URL
342
373
  const currentImageUrl = computed(() => {
343
- return imageUrls.value[currentPage.value - 1] || '';
374
+ return imageUrls.value[currentPage.value - 1] || "";
344
375
  });
345
376
 
346
377
  const rotation = ref(0);
@@ -357,12 +388,42 @@ const isScrollPaging = ref(false); // 标记是否正在进行滚动翻页
357
388
 
358
389
  // 图片和容器引用
359
390
  const containerRef = ref<HTMLElement>();
360
- const imageRef = ref<HTMLImageElement>();
361
- const textLayerRef = ref<HTMLElement>();
391
+ const imageRefs = new Map<number, HTMLImageElement>();
392
+ const textLayerRefs = new Map<number, HTMLElement>();
362
393
  const annotationButtonRef = ref<HTMLElement>();
363
394
 
364
- // 图片尺寸
365
- const imageSize = ref({ width: 0, height: 0 });
395
+ // 图片尺寸(存储每页的尺寸)
396
+ const imageSizes = new Map<number, { width: number; height: number }>();
397
+
398
+ // 设置图片引用
399
+ const setImageRef = (el: any, pageNum: number) => {
400
+ if (el) {
401
+ imageRefs.set(pageNum, el);
402
+ } else {
403
+ imageRefs.delete(pageNum);
404
+ }
405
+ };
406
+
407
+ // 设置文本图层引用
408
+ const setTextLayerRef = (el: any, pageNum: number) => {
409
+ if (el) {
410
+ textLayerRefs.set(pageNum, el);
411
+ } else {
412
+ textLayerRefs.delete(pageNum);
413
+ }
414
+ };
415
+
416
+ // 获取当前页的图片引用(用于兼容旧代码)
417
+ const imageRef = computed(() => imageRefs.get(currentPage.value));
418
+ const textLayerRef = computed(() => textLayerRefs.get(currentPage.value));
419
+
420
+ // 图片尺寸(用于兼容旧代码,返回当前页的尺寸)
421
+ const imageSize = computed({
422
+ get: () => imageSizes.get(currentPage.value) || { width: 0, height: 0 },
423
+ set: (value) => {
424
+ imageSizes.set(currentPage.value, value);
425
+ },
426
+ });
366
427
 
367
428
  // 计算图片放大后的实际尺寸(考虑旋转)
368
429
  const scaledImageSize = computed(() => {
@@ -380,7 +441,7 @@ const scaledImageSize = computed(() => {
380
441
 
381
442
  return {
382
443
  width: isRotated ? scaledHeight : scaledWidth,
383
- height: isRotated ? scaledWidth : scaledHeight
444
+ height: isRotated ? scaledWidth : scaledHeight,
384
445
  };
385
446
  });
386
447
 
@@ -392,23 +453,26 @@ const containerStyle = computed(() => {
392
453
  // 让容器自适应内容,避免横向滚动条
393
454
  if (props.autoFitWidth) {
394
455
  return {
395
- minWidth: '100%', // 使用 100% 让容器自适应父容器宽度
396
- minHeight: height > 0 ? `${height}px` : '100%'
456
+ minWidth: "100%", // 使用 100% 让容器自适应父容器宽度
457
+ minHeight: height > 0 ? `${height}px` : "100%",
397
458
  };
398
459
  }
399
460
 
400
461
  // 未启用自适应宽度时,使用原始逻辑
401
462
  return {
402
- minWidth: width > 0 ? `${width}px` : '100%',
403
- minHeight: height > 0 ? `${height}px` : '100%'
463
+ minWidth: width > 0 ? `${width}px` : "100%",
464
+ minHeight: height > 0 ? `${height}px` : "100%",
404
465
  };
405
466
  });
406
467
 
407
468
  // 批注相关状态
408
469
  const showAnnotationPopup = ref(false); // 是否显示批注弹窗
409
470
  const annotationPopupStyle = ref<any>({}); // 批注弹窗样式
410
- const annotationInput = ref(''); // 批注输入内容
411
- const currentAnnotationBlock = ref<{ bbox: [number, number, number, number]; content: string } | null>(null); // 当前正在添加批注的文本块
471
+ const annotationInput = ref(""); // 批注输入内容
472
+ const currentAnnotationBlock = ref<{
473
+ bbox: [number, number, number, number];
474
+ content: string;
475
+ } | null>(null); // 当前正在添加批注的文本块
412
476
  const annotationPopupRef = ref<HTMLElement>(); // 批注弹窗引用
413
477
 
414
478
  // 文本选择相关状态(保留用于兼容)
@@ -421,14 +485,22 @@ const currentPageBlocksData = computed(() => {
421
485
  return [];
422
486
  }
423
487
  // 根据当前页码过滤
424
- return props.blocksData.filter(block => block.pageNo === currentPage.value);
488
+ return props.blocksData.filter((block) => block.pageNo === currentPage.value);
425
489
  });
426
490
 
491
+ // 获取指定页码的blocksData
492
+ const getPageBlocksData = (pageNo: number) => {
493
+ if (!props.blocksData || props.blocksData.length === 0) {
494
+ return [];
495
+ }
496
+ return props.blocksData.filter((block) => block.pageNo === pageNo);
497
+ };
498
+
427
499
  // 隐藏定时器
428
500
  let hideTimer: any = null;
429
501
 
430
- const rotateImage = direction => {
431
- if (direction === 'left') {
502
+ const rotateImage = (direction) => {
503
+ if (direction === "left") {
432
504
  rotation.value = (rotation.value - 90) % 360;
433
505
  } else {
434
506
  rotation.value = (rotation.value + 90) % 360;
@@ -438,7 +510,7 @@ const rotateImage = direction => {
438
510
  const zoom = (delta, isWheel = false) => {
439
511
  // 用户主动缩放时,标记为手动缩放
440
512
  isUserZooming.value = true;
441
-
513
+
442
514
  const step = isWheel ? props.wheelStep : props.clickStep; // 滚轮缩放使用更小的步长
443
515
  const newScale = scale.value + delta * step;
444
516
  if (newScale <= props.minScale) {
@@ -464,7 +536,7 @@ const handleWheel = (e: WheelEvent) => {
464
536
  const startPan = (e: MouseEvent) => {
465
537
  // 只在左键点击时启用拖拽
466
538
  if (e.button !== 0) return;
467
-
539
+
468
540
  // 如果有文本图层数据,默认允许文本选择,不拖拽
469
541
  // 只有在按住 Ctrl 或 Meta 键时才允许拖拽
470
542
  if (currentPageBlocksData.value && currentPageBlocksData.value.length > 0) {
@@ -478,7 +550,7 @@ const startPan = (e: MouseEvent) => {
478
550
  return;
479
551
  }
480
552
  }
481
-
553
+
482
554
  isPanning.value = true;
483
555
  lastPosition.value = { x: e.clientX, y: e.clientY };
484
556
  e.preventDefault(); // 阻止默认行为,避免与滚动冲突
@@ -492,7 +564,7 @@ const pan = (e: MouseEvent) => {
492
564
 
493
565
  position.value = {
494
566
  x: position.value.x + deltaX,
495
- y: position.value.y + deltaY
567
+ y: position.value.y + deltaY,
496
568
  };
497
569
 
498
570
  lastPosition.value = { x: e.clientX, y: e.clientY };
@@ -501,23 +573,35 @@ const pan = (e: MouseEvent) => {
501
573
 
502
574
  const stopPan = () => {
503
575
  isPanning.value = false;
504
-
576
+
505
577
  // 在自适应宽度模式下,如果用户没有主动缩放,确保缩放比例保持初始值
506
- if (props.autoFitWidth &&
507
- !isUserZooming.value &&
508
- initialAutoFitScale.value !== null &&
509
- Math.abs(scale.value - initialAutoFitScale.value) > 0.01) {
578
+ if (
579
+ props.autoFitWidth &&
580
+ !isUserZooming.value &&
581
+ initialAutoFitScale.value !== null &&
582
+ Math.abs(scale.value - initialAutoFitScale.value) > 0.01
583
+ ) {
510
584
  // 拖拽操作不应该改变缩放比例,如果被改变了,恢复它
511
585
  scale.value = initialAutoFitScale.value;
512
586
  }
587
+ };
513
588
 
589
+ // 处理图片鼠标按下事件(允许文本选择)
590
+ const handleImageMouseDown = (e: MouseEvent) => {
591
+ // 如果点击的是文本块,不阻止默认行为,允许文本选择
592
+ const target = e.target as HTMLElement;
593
+ if (target.closest(".text-block")) {
594
+ return; // 允许文本选择
595
+ }
596
+ // 否则阻止默认行为,允许拖拽
597
+ e.preventDefault();
514
598
  };
515
599
 
516
600
  // 处理鼠标抬起事件(检测文本选择)
517
601
  const handleMouseUp = (e: MouseEvent) => {
518
602
  // 先执行停止拖拽的逻辑
519
603
  stopPan();
520
-
604
+
521
605
  // 延迟检测文本选择,确保选择已完成
522
606
  setTimeout(() => {
523
607
  checkTextSelection(e);
@@ -530,19 +614,25 @@ const checkTextSelection = (e: MouseEvent) => {
530
614
  if (currentAnnotationBlock.value) {
531
615
  return;
532
616
  }
533
-
617
+
534
618
  const target = e.target as HTMLElement;
535
619
  // 如果点击的是批注按钮或批注弹窗,不执行文本选择检测
536
- if (target.closest('.annotation-button-popup') || target.closest('.annotation-input-popup')) {
620
+ if (
621
+ target.closest(".annotation-button-popup") ||
622
+ target.closest(".annotation-input-popup")
623
+ ) {
537
624
  return;
538
625
  }
539
-
626
+
540
627
  const selection = window.getSelection();
541
-
628
+
542
629
  // 如果没有选中文本,隐藏批注按钮(但不关闭批注输入弹窗)
543
630
  if (!selection || selection.toString().trim().length === 0) {
544
631
  // 如果点击的不是文本块,且不在批注弹窗内,隐藏批注按钮
545
- if (!target.closest('.text-block') && !target.closest('.annotation-input-popup')) {
632
+ if (
633
+ !target.closest(".text-block") &&
634
+ !target.closest(".annotation-input-popup")
635
+ ) {
546
636
  // 只有在没有打开批注输入弹窗时才隐藏批注按钮
547
637
  if (!currentAnnotationBlock.value) {
548
638
  showAnnotationPopup.value = false;
@@ -551,92 +641,100 @@ const checkTextSelection = (e: MouseEvent) => {
551
641
  }
552
642
  return;
553
643
  }
554
-
644
+
555
645
  // 获取选中的文本
556
646
  const selectedText = selection.toString().trim();
557
647
  if (!selectedText) {
558
648
  return;
559
649
  }
560
-
650
+
561
651
  // 检查选中的文本是否在文本块内
562
652
  const range = selection.getRangeAt(0);
563
653
  const container = range.commonAncestorContainer;
564
-
654
+
565
655
  // 向上查找最近的文本块元素
566
656
  let textBlockElement: HTMLElement | null = null;
567
- let node: Node | null = container.nodeType === Node.TEXT_NODE ? container.parentElement : container as HTMLElement;
568
-
657
+ let node: Node | null =
658
+ container.nodeType === Node.TEXT_NODE
659
+ ? container.parentElement
660
+ : (container as HTMLElement);
661
+
569
662
  while (node && node !== document.body) {
570
- if (node instanceof HTMLElement && node.classList.contains('text-block')) {
663
+ if (node instanceof HTMLElement && node.classList.contains("text-block")) {
571
664
  textBlockElement = node;
572
665
  break;
573
666
  }
574
667
  node = node.parentElement;
575
668
  }
576
-
669
+
577
670
  if (!textBlockElement) {
578
671
  return;
579
672
  }
580
-
673
+
581
674
  // 设置当前激活的文本块
582
675
  activeBlockDiv.value = textBlockElement;
583
-
676
+
584
677
  // 获取选中文本的位置
585
678
  const rect = range.getBoundingClientRect();
586
679
  const containerRect = containerRef.value?.getBoundingClientRect();
587
-
680
+
588
681
  if (!containerRect || !containerRef.value) return;
589
-
682
+
590
683
  // 计算批注按钮的位置
591
684
  const buttonWidth = 80;
592
685
  const popupWidth = buttonWidth + 8;
593
686
  const popupHeight = 38;
594
687
  const spacing = 4; // 减少间距,让按钮更靠近文字
595
-
688
+
596
689
  // 计算相对于容器的坐标(考虑滚动)
597
- const relativeX = rect.left - containerRect.left + containerRef.value.scrollLeft;
690
+ const relativeX =
691
+ rect.left - containerRect.left + containerRef.value.scrollLeft;
598
692
  const relativeY = rect.top - containerRect.top + containerRef.value.scrollTop;
599
-
693
+
600
694
  // 容器可用空间
601
695
  const containerWidth = containerRef.value.clientWidth;
602
-
696
+
603
697
  // 计算可用空间
604
698
  const rectBottom = rect.bottom;
605
699
  const rectTop = rect.top;
606
700
  const containerBottom = containerRect.bottom;
607
701
  const containerTop = containerRect.top;
608
-
702
+
609
703
  const bottomSpace = containerBottom - rectBottom;
610
704
  const topSpace = rectTop - containerTop;
611
-
612
- const showOnBottom = bottomSpace >= popupHeight + spacing || (bottomSpace > 0 && bottomSpace >= topSpace);
613
-
705
+
706
+ const showOnBottom =
707
+ bottomSpace >= popupHeight + spacing ||
708
+ (bottomSpace > 0 && bottomSpace >= topSpace);
709
+
614
710
  // 计算最终位置
615
711
  let left: number;
616
712
  let top: number;
617
-
713
+
618
714
  const centerX = relativeX + rect.width / 2;
619
715
  left = centerX - popupWidth / 2;
620
-
716
+
621
717
  if (showOnBottom) {
622
718
  top = relativeY + rect.height + spacing;
623
719
  } else {
624
720
  top = relativeY - popupHeight - spacing;
625
721
  }
626
-
722
+
627
723
  // 边界检查
628
- const popupTopInViewport = top - containerRef.value.scrollTop + containerRect.top;
724
+ const popupTopInViewport =
725
+ top - containerRef.value.scrollTop + containerRect.top;
629
726
  const popupBottomInViewport = popupTopInViewport + popupHeight;
630
-
727
+
631
728
  if (popupBottomInViewport > containerBottom - spacing) {
632
729
  top = relativeY - popupHeight - spacing;
633
730
  }
634
-
635
- const newPopupTopInViewport = top - containerRef.value.scrollTop + containerRect.top;
731
+
732
+ const newPopupTopInViewport =
733
+ top - containerRef.value.scrollTop + containerRect.top;
636
734
  if (newPopupTopInViewport < containerTop + spacing) {
637
735
  top = relativeY + rect.height + spacing;
638
736
  }
639
-
737
+
640
738
  // 确保不超出容器边界
641
739
  if (left + popupWidth > containerWidth - spacing) {
642
740
  left = containerWidth - popupWidth - spacing;
@@ -644,12 +742,12 @@ const checkTextSelection = (e: MouseEvent) => {
644
742
  if (left < spacing) {
645
743
  left = spacing;
646
744
  }
647
-
745
+
648
746
  annotationPopupStyle.value = {
649
747
  left: `${left}px`,
650
- top: `${top}px`
748
+ top: `${top}px`,
651
749
  };
652
-
750
+
653
751
  // 显示批注按钮
654
752
  showAnnotationPopup.value = true;
655
753
  };
@@ -671,53 +769,38 @@ const switchToPage = (page: number) => {
671
769
  if (page < 1 || page > totalPages.value) {
672
770
  return;
673
771
  }
674
-
675
- // 切换页码前重置状态(但不重置缩放,等图片加载后再设置)
676
- rotation.value = 0;
677
- position.value = { x: 0, y: 0 };
678
- isUserZooming.value = false;
679
-
772
+
680
773
  // 重置文本选择状态和批注状态
681
774
  showAnnotationPopup.value = false;
682
775
  currentAnnotationBlock.value = null;
683
- annotationInput.value = '';
776
+ annotationInput.value = "";
684
777
  activeBlockDiv.value = null;
685
778
  isHighlighted.value = false;
686
-
687
- // 先更新页码,这样图片会立即切换
779
+
780
+ // 更新页码
688
781
  currentPage.value = page;
689
-
782
+
690
783
  // 触发页码变化事件
691
- emit('page-change', page, totalPages.value);
692
-
693
- // 确保滚动位置在顶部(在滚动翻页时已经设置,这里作为保险)
784
+ emit("page-change", page, totalPages.value);
785
+
786
+ // 滚动到对应页面
694
787
  if (containerRef.value) {
695
- containerRef.value.scrollTop = 0;
696
- }
697
-
698
- // 等待图片加载后重新渲染文本图层和自适应宽度
699
- nextTick(() => {
700
- if (imageRef.value && imageRef.value.complete) {
701
- // 如果启用自适应宽度,重新计算缩放比例
702
- if (props.autoFitWidth) {
703
- nextTick(() => {
704
- setTimeout(() => {
705
- const autoScale = calculateAutoFitScale();
706
- if (autoScale !== 1 && autoScale > 0) {
707
- scale.value = autoScale;
708
- initialAutoFitScale.value = autoScale; // 更新初始自适应缩放比例
709
- }
710
- }, 50);
711
- });
712
- }
713
- renderTextLayer();
788
+ const pageElement = containerRef.value.querySelector(
789
+ `[data-page-number="${page}"]`
790
+ ) as HTMLElement;
791
+ if (pageElement) {
792
+ pageElement.scrollIntoView({ behavior: "smooth", block: "start" });
793
+ // 更新 lastScrollTop,确保滚动方向判断准确
794
+ nextTick(() => {
795
+ lastScrollTop = containerRef.value?.scrollTop || 0;
796
+ });
714
797
  }
715
- });
798
+ }
716
799
  };
717
800
 
718
801
  const reset = () => {
719
802
  rotation.value = 0;
720
-
803
+
721
804
  // 如果启用自适应宽度且有初始缩放比例,恢复到初始缩放比例
722
805
  if (props.autoFitWidth && initialAutoFitScale.value !== null) {
723
806
  scale.value = initialAutoFitScale.value;
@@ -725,24 +808,28 @@ const reset = () => {
725
808
  } else {
726
809
  scale.value = 1;
727
810
  }
728
-
811
+
729
812
  position.value = { x: 0, y: 0 };
730
-
813
+
731
814
  // 重置文本选择状态和批注状态
732
815
  showAnnotationPopup.value = false;
733
816
  currentAnnotationBlock.value = null;
734
- annotationInput.value = '';
817
+ annotationInput.value = "";
735
818
  activeBlockDiv.value = null;
736
819
  isHighlighted.value = false;
737
820
  };
738
821
 
739
822
  const original = () => {
740
- emit('original');
823
+ emit("original");
741
824
  };
742
825
 
743
826
  // 计算自适应宽度的缩放比例
744
827
  const calculateAutoFitScale = () => {
745
- if (!props.autoFitWidth || !containerRef.value || imageSize.value.width === 0) {
828
+ if (
829
+ !props.autoFitWidth ||
830
+ !containerRef.value ||
831
+ imageSize.value.width === 0
832
+ ) {
746
833
  return 1;
747
834
  }
748
835
 
@@ -750,7 +837,7 @@ const calculateAutoFitScale = () => {
750
837
  // 使用容器的完整宽度,但预留足够空间避免因浏览器渲染误差导致横向滚动条
751
838
  // 预留 4px 以确保不会因为任何渲染误差导致滚动条
752
839
  const containerWidth = containerRect.width - 4;
753
-
840
+
754
841
  if (containerWidth <= 0) {
755
842
  return 1;
756
843
  }
@@ -758,33 +845,35 @@ const calculateAutoFitScale = () => {
758
845
  // 考虑旋转角度,如果旋转了90度或270度,需要交换宽高
759
846
  const normalizedRotation = ((rotation.value % 360) + 360) % 360;
760
847
  const isRotated = normalizedRotation === 90 || normalizedRotation === 270;
761
-
848
+
762
849
  const imageWidth = isRotated ? imageSize.value.height : imageSize.value.width;
763
-
850
+
764
851
  if (imageWidth <= 0) {
765
852
  return 1;
766
853
  }
767
-
854
+
768
855
  // 计算缩放比例,使图片宽度完全适应容器宽度
769
856
  const calculatedScale = containerWidth / imageWidth;
770
-
857
+
771
858
  // 确保缩放比例在允许的范围内
772
859
  return Math.max(props.minScale, Math.min(props.maxScale, calculatedScale));
773
860
  };
774
861
 
775
862
  // 图片加载完成处理
776
- const onImageLoad = (event: Event) => {
863
+ const onImageLoad = (event: Event, pageNum: number) => {
777
864
  const img = event.target as HTMLImageElement;
778
- imageSize.value = {
779
- width: img.naturalWidth,
780
- height: img.naturalHeight
781
- };
782
865
 
783
- // 如果启用自适应宽度,计算并设置初始缩放比例
784
- if (props.autoFitWidth) {
866
+ // 存储该页的图片尺寸
867
+ imageSizes.set(pageNum, {
868
+ width: img.naturalWidth,
869
+ height: img.naturalHeight,
870
+ });
871
+
872
+ // 如果是第一页且启用自适应宽度,计算并设置初始缩放比例
873
+ if (pageNum === 1 && props.autoFitWidth) {
785
874
  // 重置用户缩放标记
786
875
  isUserZooming.value = false;
787
-
876
+
788
877
  // 使用双重 nextTick 确保容器尺寸已确定
789
878
  nextTick(() => {
790
879
  nextTick(() => {
@@ -799,17 +888,17 @@ const onImageLoad = (event: Event) => {
799
888
  });
800
889
  });
801
890
  }
802
-
803
- emit('load', {
891
+
892
+ emit("load", {
804
893
  width: img.naturalWidth,
805
894
  height: img.naturalHeight,
806
- page: currentPage.value,
807
- totalPages: totalPages.value
895
+ page: pageNum,
896
+ totalPages: totalPages.value,
808
897
  });
809
-
810
- // 图片加载完成后,渲染文本图层
898
+
899
+ // 图片加载完成后,渲染该页的文本图层
811
900
  nextTick(() => {
812
- renderTextLayer();
901
+ renderTextLayer(pageNum);
813
902
  });
814
903
  };
815
904
 
@@ -825,7 +914,7 @@ const onImageLoad = (event: Event) => {
825
914
  const calculateFontSize = (
826
915
  text: string,
827
916
  targetWidth: number,
828
- targetHeight: number,
917
+ targetHeight: number
829
918
  ): number => {
830
919
  // 创建一个离屏 Canvas 用于测量文本
831
920
  const canvas = document.createElement("canvas");
@@ -872,19 +961,21 @@ const calculateFontSize = (
872
961
  /**
873
962
  * 渲染文本图层(使用 blocksData 数据)
874
963
  */
875
- const renderTextLayer = () => {
876
- const textLayer = textLayerRef.value;
877
- const image = imageRef.value;
964
+ const renderTextLayer = (pageNum?: number) => {
965
+ const targetPage = pageNum || currentPage.value;
966
+ const textLayer = textLayerRefs.get(targetPage);
967
+ const image = imageRefs.get(targetPage);
878
968
 
879
969
  if (!textLayer || !image) {
880
970
  return;
881
971
  }
882
972
 
883
- console.log('renderTextLayer', currentPageBlocksData.value);
973
+ const pageBlocksData = getPageBlocksData(targetPage);
974
+ console.log("renderTextLayer", targetPage, pageBlocksData);
884
975
 
885
976
  // 如果没有提供分块数据,跳过渲染
886
- if (!currentPageBlocksData.value || currentPageBlocksData.value.length === 0) {
887
- textLayer.innerHTML = '';
977
+ if (!pageBlocksData || pageBlocksData.length === 0) {
978
+ textLayer.innerHTML = "";
888
979
  return;
889
980
  }
890
981
 
@@ -894,10 +985,10 @@ const renderTextLayer = () => {
894
985
  textLayer.style.height = `${image.naturalHeight}px`;
895
986
 
896
987
  // 清空文本图层
897
- textLayer.innerHTML = '';
988
+ textLayer.innerHTML = "";
898
989
 
899
- // 使用当前页码的 blocksData 创建可交互的块
900
- currentPageBlocksData.value.forEach((block, index) => {
990
+ // 使用指定页码的 blocksData 创建可交互的块
991
+ pageBlocksData.forEach((block, index) => {
901
992
  const { content, bbox } = block;
902
993
 
903
994
  // bbox 格式: [x1, y1, x2, y2]
@@ -909,40 +1000,40 @@ const renderTextLayer = () => {
909
1000
  const calculatedFontSize = calculateFontSize(content, width, height);
910
1001
 
911
1002
  // 创建文本块
912
- const blockDiv = document.createElement('div');
913
- blockDiv.className = 'text-block';
1003
+ const blockDiv = document.createElement("div");
1004
+ blockDiv.className = "text-block";
914
1005
  blockDiv.dataset.text = content;
915
1006
  blockDiv.dataset.bbox = JSON.stringify(bbox);
916
1007
 
917
1008
  // 设置基础样式
918
- blockDiv.style.position = 'absolute';
1009
+ blockDiv.style.position = "absolute";
919
1010
  blockDiv.style.left = `${x1}px`;
920
1011
  blockDiv.style.top = `${y1}px`;
921
1012
  blockDiv.style.width = `${width}px`;
922
1013
  blockDiv.style.height = `${height}px`;
923
- blockDiv.style.zIndex = '20'; // 确保文本块在图片上方
924
- blockDiv.style.cursor = 'text'; // 改为文本选择光标
1014
+ blockDiv.style.zIndex = "20"; // 确保文本块在图片上方
1015
+ blockDiv.style.cursor = "text"; // 改为文本选择光标
925
1016
  // 设置文本内容(使用 textContent 而不是 innerHTML,避免 XSS 风险)
926
1017
  blockDiv.textContent = content;
927
1018
  // 设置文本样式,确保可以选择和显示
928
- blockDiv.style.color = 'red'; // 红色文字
1019
+ blockDiv.style.color = "red"; // 红色文字
929
1020
  blockDiv.style.fontSize = `${calculatedFontSize}px`; // 使用计算出的字体大小
930
- blockDiv.style.fontFamily = 'Arial, sans-serif'; // 设置明确的字体
931
- blockDiv.style.lineHeight = '1.2'; // 设置合适的行高
932
- blockDiv.style.whiteSpace = 'pre-wrap'; // 保留换行和空格
933
- blockDiv.style.overflow = 'visible'; // 确保文字不被裁剪
934
- blockDiv.style.display = 'block'; // 确保是块级元素
935
- blockDiv.style.visibility = 'visible'; // 确保可见
1021
+ blockDiv.style.fontFamily = "Arial, sans-serif"; // 设置明确的字体
1022
+ blockDiv.style.lineHeight = "1.2"; // 设置合适的行高
1023
+ blockDiv.style.whiteSpace = "pre-wrap"; // 保留换行和空格
1024
+ blockDiv.style.overflow = "visible"; // 确保文字不被裁剪
1025
+ blockDiv.style.display = "block"; // 确保是块级元素
1026
+ blockDiv.style.visibility = "visible"; // 确保可见
936
1027
  // 允许文本选择
937
- blockDiv.style.userSelect = 'text';
938
- blockDiv.style.webkitUserSelect = 'text';
939
- blockDiv.style.mozUserSelect = 'text';
940
- blockDiv.style.msUserSelect = 'text';
1028
+ blockDiv.style.userSelect = "text";
1029
+ blockDiv.style.webkitUserSelect = "text";
1030
+ blockDiv.style.mozUserSelect = "text";
1031
+ blockDiv.style.msUserSelect = "text";
941
1032
  // 允许指针事件,但不阻止文本选择
942
- blockDiv.style.pointerEvents = 'auto';
1033
+ blockDiv.style.pointerEvents = "auto";
943
1034
 
944
1035
  // 右键菜单:显示批注按钮
945
- blockDiv.addEventListener('contextmenu', (e) => {
1036
+ blockDiv.addEventListener("contextmenu", (e) => {
946
1037
  e.preventDefault();
947
1038
  // 设置当前文本块为激活状态
948
1039
  activeBlockDiv.value = blockDiv;
@@ -954,78 +1045,86 @@ const renderTextLayer = () => {
954
1045
  const existingAnnotation = getAnnotationForBlock(bbox);
955
1046
  if (existingAnnotation) {
956
1047
  // 添加批注标记样式类
957
- blockDiv.classList.add('has-annotation');
1048
+ blockDiv.classList.add("has-annotation");
958
1049
  blockDiv.title = `已有批注: ${existingAnnotation.content}`;
959
1050
  // 直接设置样式,确保生效(内联样式优先级高于CSS类)
960
- blockDiv.style.backgroundColor = 'rgba(255, 243, 205, 0.5)';
961
- blockDiv.style.border = '1px solid rgba(255, 193, 7, 0.7)';
962
- blockDiv.style.borderRadius = '3px';
963
- blockDiv.style.padding = '1px 3px';
964
- blockDiv.style.boxShadow = '0 1px 2px rgba(255, 193, 7, 0.25)';
965
-
1051
+ blockDiv.style.backgroundColor = "rgba(255, 243, 205, 0.5)";
1052
+ blockDiv.style.border = "1px solid rgba(255, 193, 7, 0.7)";
1053
+ blockDiv.style.borderRadius = "3px";
1054
+ blockDiv.style.padding = "1px 3px";
1055
+ blockDiv.style.boxShadow = "0 1px 2px rgba(255, 193, 7, 0.25)";
1056
+
966
1057
  // 创建批注图标标记
967
- const annotationMarker = document.createElement('span');
968
- annotationMarker.className = 'annotation-marker';
969
- annotationMarker.textContent = '📝';
970
- annotationMarker.style.position = 'absolute';
971
- annotationMarker.style.top = '-6px';
972
- annotationMarker.style.right = '-6px';
973
- annotationMarker.style.fontSize = '11px';
974
- annotationMarker.style.backgroundColor = 'rgba(255, 193, 7, 0.95)';
975
- annotationMarker.style.borderRadius = '50%';
976
- annotationMarker.style.width = '16px';
977
- annotationMarker.style.height = '16px';
978
- annotationMarker.style.display = 'flex';
979
- annotationMarker.style.alignItems = 'center';
980
- annotationMarker.style.justifyContent = 'center';
981
- annotationMarker.style.zIndex = '30';
982
- annotationMarker.style.boxShadow = '0 1px 3px rgba(0, 0, 0, 0.25)';
983
- annotationMarker.style.lineHeight = '1';
1058
+ const annotationMarker = document.createElement("span");
1059
+ annotationMarker.className = "annotation-marker";
1060
+ annotationMarker.textContent = "📝";
1061
+ annotationMarker.style.position = "absolute";
1062
+ annotationMarker.style.top = "-6px";
1063
+ annotationMarker.style.right = "-6px";
1064
+ annotationMarker.style.fontSize = "11px";
1065
+ annotationMarker.style.backgroundColor = "rgba(255, 193, 7, 0.95)";
1066
+ annotationMarker.style.borderRadius = "50%";
1067
+ annotationMarker.style.width = "16px";
1068
+ annotationMarker.style.height = "16px";
1069
+ annotationMarker.style.display = "flex";
1070
+ annotationMarker.style.alignItems = "center";
1071
+ annotationMarker.style.justifyContent = "center";
1072
+ annotationMarker.style.zIndex = "30";
1073
+ annotationMarker.style.boxShadow = "0 1px 3px rgba(0, 0, 0, 0.25)";
1074
+ annotationMarker.style.lineHeight = "1";
984
1075
  blockDiv.appendChild(annotationMarker);
985
1076
  } else {
986
1077
  // 没有批注时设置为透明背景
987
- blockDiv.style.backgroundColor = 'transparent';
1078
+ blockDiv.style.backgroundColor = "transparent";
988
1079
  }
989
1080
 
990
1081
  textLayer.appendChild(blockDiv);
991
1082
  });
992
1083
  } catch (error) {
993
- console.error('❌ 文本图层渲染失败:', error);
1084
+ console.error("❌ 文本图层渲染失败:", error);
994
1085
  }
995
1086
  };
996
1087
 
997
1088
  /**
998
1089
  * 获取文本块对应的批注
999
1090
  */
1000
- const getAnnotationForBlock = (bbox: [number, number, number, number]): AnnotationInfo | null => {
1091
+ const getAnnotationForBlock = (
1092
+ bbox: [number, number, number, number]
1093
+ ): AnnotationInfo | null => {
1001
1094
  if (!props.annotations || props.annotations.length === 0) {
1002
1095
  return null;
1003
1096
  }
1004
-
1097
+
1005
1098
  const tolerance = 2; // 容差
1006
- return props.annotations.find(annotation => {
1007
- const [x1, y1, x2, y2] = annotation.blockBbox;
1008
- return (
1009
- Math.abs(x1 - bbox[0]) < tolerance &&
1010
- Math.abs(y1 - bbox[1]) < tolerance &&
1011
- Math.abs(x2 - bbox[2]) < tolerance &&
1012
- Math.abs(y2 - bbox[3]) < tolerance &&
1013
- (annotation.blockPage === undefined || annotation.blockPage === currentPage.value)
1014
- );
1015
- }) || null;
1099
+ return (
1100
+ props.annotations.find((annotation) => {
1101
+ const [x1, y1, x2, y2] = annotation.blockBbox;
1102
+ return (
1103
+ Math.abs(x1 - bbox[0]) < tolerance &&
1104
+ Math.abs(y1 - bbox[1]) < tolerance &&
1105
+ Math.abs(x2 - bbox[2]) < tolerance &&
1106
+ Math.abs(y2 - bbox[3]) < tolerance &&
1107
+ (annotation.blockPage === undefined ||
1108
+ annotation.blockPage === currentPage.value)
1109
+ );
1110
+ }) || null
1111
+ );
1016
1112
  };
1017
1113
 
1018
1114
  /**
1019
1115
  * 显示文本块的批注按钮
1020
1116
  */
1021
- const showAnnotationButtonForBlock = (event: MouseEvent, blockDiv: HTMLElement) => {
1022
- const text = blockDiv.dataset.text || '';
1023
- const bboxStr = blockDiv.dataset.bbox || '';
1117
+ const showAnnotationButtonForBlock = (
1118
+ event: MouseEvent,
1119
+ blockDiv: HTMLElement
1120
+ ) => {
1121
+ const text = blockDiv.dataset.text || "";
1122
+ const bboxStr = blockDiv.dataset.bbox || "";
1024
1123
  if (!text || !bboxStr) return;
1025
1124
 
1026
1125
  try {
1027
1126
  const bbox = JSON.parse(bboxStr) as [number, number, number, number];
1028
-
1127
+
1029
1128
  const rect = blockDiv.getBoundingClientRect();
1030
1129
  const containerRect = containerRef.value?.getBoundingClientRect();
1031
1130
 
@@ -1038,8 +1137,10 @@ const showAnnotationButtonForBlock = (event: MouseEvent, blockDiv: HTMLElement)
1038
1137
  const spacing = 8;
1039
1138
 
1040
1139
  // 计算相对于容器的坐标(考虑滚动)
1041
- const relativeX = rect.left - containerRect.left + containerRef.value.scrollLeft;
1042
- const relativeY = rect.top - containerRect.top + containerRef.value.scrollTop;
1140
+ const relativeX =
1141
+ rect.left - containerRect.left + containerRef.value.scrollLeft;
1142
+ const relativeY =
1143
+ rect.top - containerRect.top + containerRef.value.scrollTop;
1043
1144
 
1044
1145
  // 容器可用空间
1045
1146
  const containerWidth = containerRef.value.clientWidth;
@@ -1053,7 +1154,9 @@ const showAnnotationButtonForBlock = (event: MouseEvent, blockDiv: HTMLElement)
1053
1154
  const bottomSpace = containerBottom - rectBottom;
1054
1155
  const topSpace = rectTop - containerTop;
1055
1156
 
1056
- const showOnBottom = bottomSpace >= popupHeight + spacing || (bottomSpace > 0 && bottomSpace >= topSpace);
1157
+ const showOnBottom =
1158
+ bottomSpace >= popupHeight + spacing ||
1159
+ (bottomSpace > 0 && bottomSpace >= topSpace);
1057
1160
 
1058
1161
  // 计算最终位置
1059
1162
  let left: number;
@@ -1069,14 +1172,16 @@ const showAnnotationButtonForBlock = (event: MouseEvent, blockDiv: HTMLElement)
1069
1172
  }
1070
1173
 
1071
1174
  // 边界检查
1072
- const popupTopInViewport = top - containerRef.value.scrollTop + containerRect.top;
1175
+ const popupTopInViewport =
1176
+ top - containerRef.value.scrollTop + containerRect.top;
1073
1177
  const popupBottomInViewport = popupTopInViewport + popupHeight;
1074
1178
 
1075
1179
  if (popupBottomInViewport > containerBottom - spacing) {
1076
1180
  top = relativeY - popupHeight - spacing;
1077
1181
  }
1078
1182
 
1079
- const newPopupTopInViewport = top - containerRef.value.scrollTop + containerRect.top;
1183
+ const newPopupTopInViewport =
1184
+ top - containerRef.value.scrollTop + containerRect.top;
1080
1185
  if (newPopupTopInViewport < containerTop + spacing) {
1081
1186
  top = relativeY + rect.height + spacing;
1082
1187
  }
@@ -1091,12 +1196,12 @@ const showAnnotationButtonForBlock = (event: MouseEvent, blockDiv: HTMLElement)
1091
1196
 
1092
1197
  annotationPopupStyle.value = {
1093
1198
  left: `${left}px`,
1094
- top: `${top}px`
1199
+ top: `${top}px`,
1095
1200
  };
1096
1201
 
1097
1202
  showAnnotationPopup.value = true;
1098
1203
  } catch (error) {
1099
- console.error('解析bbox失败:', error);
1204
+ console.error("解析bbox失败:", error);
1100
1205
  }
1101
1206
  };
1102
1207
 
@@ -1108,13 +1213,13 @@ const hideAnnotationButton = () => {
1108
1213
  if (currentAnnotationBlock.value) {
1109
1214
  return;
1110
1215
  }
1111
-
1216
+
1112
1217
  hideTimer = setTimeout(() => {
1113
1218
  showAnnotationPopup.value = false;
1114
1219
 
1115
1220
  if (activeBlockDiv.value && !isHighlighted.value) {
1116
- activeBlockDiv.value.style.backgroundColor = 'transparent';
1117
- activeBlockDiv.value.style.boxShadow = 'none';
1221
+ activeBlockDiv.value.style.backgroundColor = "transparent";
1222
+ activeBlockDiv.value.style.boxShadow = "none";
1118
1223
  activeBlockDiv.value = null;
1119
1224
  }
1120
1225
  }, 300);
@@ -1138,69 +1243,71 @@ const openAnnotationInput = (e?: Event) => {
1138
1243
  if (e) {
1139
1244
  e.stopPropagation();
1140
1245
  }
1141
-
1246
+
1142
1247
  // 获取选中的文本
1143
1248
  const selection = window.getSelection();
1144
- let selectedText = '';
1145
-
1249
+ let selectedText = "";
1250
+
1146
1251
  if (selection && selection.toString().trim().length > 0) {
1147
1252
  selectedText = selection.toString().trim();
1148
1253
  } else if (activeBlockDiv.value) {
1149
- selectedText = activeBlockDiv.value.dataset.text || '';
1254
+ selectedText = activeBlockDiv.value.dataset.text || "";
1150
1255
  }
1151
-
1256
+
1152
1257
  if (!selectedText && !activeBlockDiv.value) return;
1153
-
1258
+
1154
1259
  // 如果有激活的文本块,使用文本块的 bbox
1155
1260
  let bbox: [number, number, number, number] | null = null;
1156
1261
  if (activeBlockDiv.value) {
1157
- const bboxStr = activeBlockDiv.value.dataset.bbox || '';
1262
+ const bboxStr = activeBlockDiv.value.dataset.bbox || "";
1158
1263
  try {
1159
1264
  bbox = JSON.parse(bboxStr) as [number, number, number, number];
1160
1265
  } catch (error) {
1161
- console.error('解析 bbox 失败:', error);
1266
+ console.error("解析 bbox 失败:", error);
1162
1267
  }
1163
1268
  }
1164
-
1269
+
1165
1270
  // 如果没有 bbox,尝试从选中文本的位置计算
1166
1271
  if (!bbox && selection && selection.rangeCount > 0) {
1167
1272
  const range = selection.getRangeAt(0);
1168
1273
  const rect = range.getBoundingClientRect();
1169
1274
  const containerRect = containerRef.value?.getBoundingClientRect();
1170
-
1275
+
1171
1276
  if (containerRect && containerRef.value) {
1172
- const relativeX = rect.left - containerRect.left + containerRef.value.scrollLeft;
1173
- const relativeY = rect.top - containerRect.top + containerRef.value.scrollTop;
1277
+ const relativeX =
1278
+ rect.left - containerRect.left + containerRef.value.scrollLeft;
1279
+ const relativeY =
1280
+ rect.top - containerRect.top + containerRef.value.scrollTop;
1174
1281
  bbox = [
1175
1282
  relativeX,
1176
1283
  relativeY,
1177
1284
  relativeX + rect.width,
1178
- relativeY + rect.height
1285
+ relativeY + rect.height,
1179
1286
  ];
1180
1287
  }
1181
1288
  }
1182
-
1289
+
1183
1290
  if (!bbox) {
1184
- Message.warning('无法确定文本位置');
1291
+ Message.warning("无法确定文本位置");
1185
1292
  return;
1186
1293
  }
1187
-
1294
+
1188
1295
  // 检查是否已有批注
1189
1296
  const existingAnnotation = getAnnotationForBlock(bbox);
1190
1297
  if (existingAnnotation) {
1191
1298
  annotationInput.value = existingAnnotation.content;
1192
1299
  } else {
1193
- annotationInput.value = '';
1300
+ annotationInput.value = "";
1194
1301
  }
1195
-
1302
+
1196
1303
  currentAnnotationBlock.value = {
1197
1304
  bbox,
1198
- content: selectedText
1305
+ content: selectedText,
1199
1306
  };
1200
-
1307
+
1201
1308
  // 确保弹窗显示
1202
1309
  showAnnotationPopup.value = true;
1203
-
1310
+
1204
1311
  // 重新计算弹窗位置(输入框更大)
1205
1312
  nextTick(() => {
1206
1313
  adjustAnnotationPopupPosition();
@@ -1213,56 +1320,62 @@ const openAnnotationInput = (e?: Event) => {
1213
1320
  const adjustAnnotationPopupPosition = () => {
1214
1321
  const containerRect = containerRef.value?.getBoundingClientRect();
1215
1322
  const popup = annotationPopupRef.value;
1216
-
1323
+
1217
1324
  if (!containerRect || !containerRef.value) {
1218
1325
  return;
1219
1326
  }
1220
-
1327
+
1221
1328
  const popupWidth = popup?.getBoundingClientRect().width || 320; // 默认宽度
1222
1329
  const popupHeight = popup?.getBoundingClientRect().height || 200; // 默认高度
1223
1330
  const spacing = 4; // 减少间距,让弹窗更靠近文字
1224
-
1331
+
1225
1332
  // 优先使用文本块位置(和批注按钮位置计算逻辑一致)
1226
1333
  let left: number;
1227
1334
  let top: number;
1228
-
1335
+
1229
1336
  if (activeBlockDiv.value) {
1230
1337
  // 使用文本块位置,和批注按钮使用相同的计算逻辑
1231
1338
  const rect = activeBlockDiv.value.getBoundingClientRect();
1232
- const relativeX = rect.left - containerRect.left + containerRef.value.scrollLeft;
1233
- const relativeY = rect.top - containerRect.top + containerRef.value.scrollTop;
1234
-
1339
+ const relativeX =
1340
+ rect.left - containerRect.left + containerRef.value.scrollLeft;
1341
+ const relativeY =
1342
+ rect.top - containerRect.top + containerRef.value.scrollTop;
1343
+
1235
1344
  // 计算可用空间
1236
1345
  const rectBottom = rect.bottom;
1237
1346
  const rectTop = rect.top;
1238
1347
  const containerBottom = containerRect.bottom;
1239
1348
  const containerTop = containerRect.top;
1240
-
1349
+
1241
1350
  const bottomSpace = containerBottom - rectBottom;
1242
1351
  const topSpace = rectTop - containerTop;
1243
-
1352
+
1244
1353
  // 判断弹窗显示在文字下方还是上方
1245
- const showOnBottom = bottomSpace >= popupHeight + spacing || (bottomSpace > 0 && bottomSpace >= topSpace);
1246
-
1354
+ const showOnBottom =
1355
+ bottomSpace >= popupHeight + spacing ||
1356
+ (bottomSpace > 0 && bottomSpace >= topSpace);
1357
+
1247
1358
  // 计算位置(和批注按钮位置计算一致)
1248
1359
  const centerX = relativeX + rect.width / 2;
1249
1360
  left = centerX - popupWidth / 2;
1250
-
1361
+
1251
1362
  if (showOnBottom) {
1252
1363
  top = relativeY + rect.height + spacing;
1253
1364
  } else {
1254
1365
  top = relativeY - popupHeight - spacing;
1255
1366
  }
1256
-
1367
+
1257
1368
  // 边界检查
1258
- const popupTopInViewport = top - containerRef.value.scrollTop + containerRect.top;
1369
+ const popupTopInViewport =
1370
+ top - containerRef.value.scrollTop + containerRect.top;
1259
1371
  const popupBottomInViewport = popupTopInViewport + popupHeight;
1260
-
1372
+
1261
1373
  if (popupBottomInViewport > containerBottom - spacing) {
1262
1374
  top = relativeY - popupHeight - spacing;
1263
1375
  }
1264
-
1265
- const newPopupTopInViewport = top - containerRef.value.scrollTop + containerRect.top;
1376
+
1377
+ const newPopupTopInViewport =
1378
+ top - containerRef.value.scrollTop + containerRect.top;
1266
1379
  if (newPopupTopInViewport < containerTop + spacing) {
1267
1380
  top = relativeY + rect.height + spacing;
1268
1381
  }
@@ -1270,13 +1383,13 @@ const adjustAnnotationPopupPosition = () => {
1270
1383
  // 如果没有文本块,使用当前按钮位置(作为后备方案)
1271
1384
  const currentStyle = annotationPopupStyle.value;
1272
1385
  if (currentStyle && currentStyle.left && currentStyle.top) {
1273
- left = parseFloat(currentStyle.left.toString().replace('px', ''));
1274
- top = parseFloat(currentStyle.top.toString().replace('px', ''));
1386
+ left = parseFloat(currentStyle.left.toString().replace("px", ""));
1387
+ top = parseFloat(currentStyle.top.toString().replace("px", ""));
1275
1388
  } else {
1276
1389
  return;
1277
1390
  }
1278
1391
  }
1279
-
1392
+
1280
1393
  // 确保不超出容器边界
1281
1394
  const containerWidth = containerRef.value.clientWidth;
1282
1395
  if (left + popupWidth > containerWidth - spacing) {
@@ -1285,10 +1398,10 @@ const adjustAnnotationPopupPosition = () => {
1285
1398
  if (left < spacing) {
1286
1399
  left = spacing;
1287
1400
  }
1288
-
1401
+
1289
1402
  annotationPopupStyle.value = {
1290
1403
  left: `${left}px`,
1291
- top: `${top}px`
1404
+ top: `${top}px`,
1292
1405
  };
1293
1406
  };
1294
1407
 
@@ -1297,7 +1410,7 @@ const adjustAnnotationPopupPosition = () => {
1297
1410
  */
1298
1411
  const closeAnnotationInput = () => {
1299
1412
  currentAnnotationBlock.value = null;
1300
- annotationInput.value = '';
1413
+ annotationInput.value = "";
1301
1414
  showAnnotationPopup.value = false;
1302
1415
  };
1303
1416
 
@@ -1310,7 +1423,7 @@ const handleAnnotationPopupLeave = (e: MouseEvent) => {
1310
1423
  if (relatedTarget && annotationPopupRef.value?.contains(relatedTarget)) {
1311
1424
  return;
1312
1425
  }
1313
-
1426
+
1314
1427
  // 延迟关闭,给用户时间操作
1315
1428
  hideTimer = setTimeout(() => {
1316
1429
  closeAnnotationInput();
@@ -1324,35 +1437,37 @@ const saveAnnotation = () => {
1324
1437
  if (!currentAnnotationBlock.value || !annotationInput.value.trim()) {
1325
1438
  return;
1326
1439
  }
1327
-
1440
+
1328
1441
  const { bbox, content } = currentAnnotationBlock.value;
1329
1442
  const annotationContent = annotationInput.value.trim();
1330
-
1443
+
1331
1444
  // 检查是否已有批注
1332
1445
  const existingAnnotation = getAnnotationForBlock(bbox);
1333
-
1446
+
1334
1447
  const annotation: AnnotationInfo = {
1335
- id: existingAnnotation?.id || `annotation_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
1448
+ id:
1449
+ existingAnnotation?.id ||
1450
+ `annotation_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
1336
1451
  blockBbox: bbox,
1337
1452
  blockContent: content,
1338
1453
  blockPage: currentPage.value,
1339
1454
  content: annotationContent,
1340
- createTime: existingAnnotation?.createTime || Date.now()
1455
+ createTime: existingAnnotation?.createTime || Date.now(),
1341
1456
  };
1342
-
1457
+
1343
1458
  if (existingAnnotation) {
1344
1459
  // 更新已有批注
1345
- emit('annotation-update', annotation);
1346
- Message.success('批注已更新');
1460
+ emit("annotation-update", annotation);
1461
+ Message.success("批注已更新");
1347
1462
  } else {
1348
1463
  // 添加新批注
1349
- emit('annotation-add', annotation);
1350
- Message.success('批注已添加');
1464
+ emit("annotation-add", annotation);
1465
+ Message.success("批注已添加");
1351
1466
  }
1352
-
1467
+
1353
1468
  // 关闭输入框
1354
1469
  closeAnnotationInput();
1355
-
1470
+
1356
1471
  // 重新渲染文本图层以显示批注标记
1357
1472
  nextTick(() => {
1358
1473
  renderTextLayer();
@@ -1372,85 +1487,134 @@ const handleScroll = (e: Event) => {
1372
1487
  showAnnotationPopup.value = false;
1373
1488
  activeBlockDiv.value = null;
1374
1489
  }
1375
-
1490
+
1376
1491
  // 在自适应宽度模式下,滚动操作不应该改变缩放比例
1377
1492
  // 如果缩放比例被意外改变,恢复它
1378
- if (props.autoFitWidth &&
1379
- !isUserZooming.value &&
1380
- initialAutoFitScale.value !== null &&
1381
- Math.abs(scale.value - initialAutoFitScale.value) > 0.01) {
1493
+ if (
1494
+ props.autoFitWidth &&
1495
+ !isUserZooming.value &&
1496
+ initialAutoFitScale.value !== null &&
1497
+ Math.abs(scale.value - initialAutoFitScale.value) > 0.01
1498
+ ) {
1382
1499
  scale.value = initialAutoFitScale.value;
1383
1500
  }
1384
-
1501
+
1385
1502
  // 滚动翻页功能
1386
- if (props.enableScrollPaging && totalPages.value > 1 && !isScrollPaging.value) {
1503
+ if (
1504
+ props.enableScrollPaging &&
1505
+ totalPages.value > 1 &&
1506
+ !isScrollPaging.value
1507
+ ) {
1387
1508
  handleScrollPaging(e);
1388
1509
  }
1389
1510
  };
1390
1511
 
1512
+ // 记录上次滚动位置,用于判断滚动方向
1513
+ let lastScrollTop = 0;
1514
+
1391
1515
  /**
1392
- * 处理滚动翻页
1516
+ * 处理滚动翻页(通过滚动位置判断当前页)
1517
+ * 向下滑动:当视口顶部到达下一页的顶部时,切换到下一页
1518
+ * 向上滑动:当视口底部到达上一页的底部时,切换到上一页
1393
1519
  */
1394
1520
  const handleScrollPaging = (e: Event) => {
1395
1521
  const container = e.target as HTMLElement;
1396
- if (!container) return;
1397
-
1522
+ if (!container || !containerRef.value) return;
1523
+
1398
1524
  // 如果正在翻页中,直接返回
1399
1525
  if (isScrollPaging.value) {
1400
1526
  return;
1401
1527
  }
1402
-
1528
+
1403
1529
  // 清除之前的定时器
1404
1530
  if (scrollPagingTimer) {
1405
1531
  clearTimeout(scrollPagingTimer);
1406
1532
  }
1407
-
1408
- // 使用防抖,避免频繁触发(增加延迟时间减少卡顿)
1533
+
1534
+ // 使用防抖,避免频繁触发
1409
1535
  scrollPagingTimer = setTimeout(() => {
1410
1536
  // 再次检查是否正在翻页
1411
1537
  if (isScrollPaging.value) {
1412
1538
  return;
1413
1539
  }
1414
-
1540
+
1415
1541
  const scrollTop = container.scrollTop;
1416
- const scrollHeight = container.scrollHeight;
1417
1542
  const clientHeight = container.clientHeight;
1543
+ const scrollBottom = scrollTop + clientHeight;
1544
+
1545
+ // 判断滚动方向
1546
+ const isScrollingDown = scrollTop > lastScrollTop;
1547
+ lastScrollTop = scrollTop;
1548
+
1549
+ // 获取当前页的元素
1550
+ const currentPageElement = container.querySelector(
1551
+ `[data-page-number="${currentPage.value}"]`
1552
+ ) as HTMLElement;
1418
1553
 
1419
- // 滚动到底部(留 100px 的缓冲区,避免误触发)
1420
- if (scrollTop + clientHeight >= scrollHeight - 100) {
1554
+ if (!currentPageElement) return;
1555
+
1556
+ const currentPageTop = currentPageElement.offsetTop;
1557
+ const currentPageHeight = currentPageElement.offsetHeight;
1558
+ const currentPageBottom = currentPageTop + currentPageHeight;
1559
+
1560
+ let newPage = currentPage.value;
1561
+
1562
+ if (isScrollingDown) {
1563
+ // 向下滑动:当视口顶部到达或超过下一页的顶部时,切换到下一页
1421
1564
  if (currentPage.value < totalPages.value) {
1422
- isScrollPaging.value = true;
1423
- // 先重置滚动位置,避免看到下一页的开头
1424
- container.scrollTop = 0;
1425
- // 然后切换页面
1426
- goToNextPage();
1427
- // 延迟解锁,确保页面切换完成
1428
- setTimeout(() => {
1429
- isScrollPaging.value = false;
1430
- }, 300);
1565
+ const nextPageElement = container.querySelector(
1566
+ `[data-page-number="${currentPage.value + 1}"]`
1567
+ ) as HTMLElement;
1568
+
1569
+ if (nextPageElement) {
1570
+ const nextPageTop = nextPageElement.offsetTop;
1571
+ // 当视口顶部到达或超过下一页的顶部时,切换到下一页
1572
+ if (scrollTop >= nextPageTop) {
1573
+ newPage = currentPage.value + 1;
1574
+ }
1575
+ }
1431
1576
  }
1432
- }
1433
- // 滚动到顶部(留 100px 的缓冲区)
1434
- else if (scrollTop <= 100) {
1577
+ } else {
1578
+ // 向上滑动:当视口底部到达或超过上一页的底部时,切换到上一页
1435
1579
  if (currentPage.value > 1) {
1436
- isScrollPaging.value = true;
1437
- // 先重置滚动位置,避免看到上一页的开头
1438
- container.scrollTop = 0;
1439
- // 然后切换页面
1440
- goToPreviousPage();
1441
- // 延迟解锁,确保页面切换完成
1442
- setTimeout(() => {
1443
- isScrollPaging.value = false;
1444
- }, 300);
1580
+ const prevPageElement = container.querySelector(
1581
+ `[data-page-number="${currentPage.value - 1}"]`
1582
+ ) as HTMLElement;
1583
+
1584
+ if (prevPageElement) {
1585
+ const prevPageTop = prevPageElement.offsetTop;
1586
+ const prevPageHeight = prevPageElement.offsetHeight;
1587
+ const prevPageBottom = prevPageTop + prevPageHeight;
1588
+
1589
+ // 当视口底部到达或超过上一页的底部时,切换到上一页
1590
+ if (scrollBottom <= prevPageBottom) {
1591
+ newPage = currentPage.value - 1;
1592
+ }
1593
+ }
1445
1594
  }
1446
1595
  }
1447
- }, 200); // 增加防抖时间到 200ms,减少卡顿
1596
+
1597
+ // 如果页码发生变化,更新当前页
1598
+ if (newPage !== currentPage.value) {
1599
+ isScrollPaging.value = true;
1600
+ currentPage.value = newPage;
1601
+ emit("page-change", newPage, totalPages.value);
1602
+
1603
+ // 延迟解锁,确保页面切换完成
1604
+ setTimeout(() => {
1605
+ isScrollPaging.value = false;
1606
+ }, 100);
1607
+ }
1608
+ }, 100);
1448
1609
  };
1449
1610
 
1450
1611
  /**
1451
1612
  * 检查元素是否在视口内可见
1452
1613
  */
1453
- const isElementVisible = (element: HTMLElement, container: HTMLElement): boolean => {
1614
+ const isElementVisible = (
1615
+ element: HTMLElement,
1616
+ container: HTMLElement
1617
+ ): boolean => {
1454
1618
  const elementRect = element.getBoundingClientRect();
1455
1619
  const containerRect = container.getBoundingClientRect();
1456
1620
 
@@ -1464,38 +1628,51 @@ const isElementVisible = (element: HTMLElement, container: HTMLElement): boolean
1464
1628
 
1465
1629
  /**
1466
1630
  * 高亮指定位置
1467
- * @param bbox 块的边界框 [x1, y1, x2, y2]
1631
+ * @param pageNum 页码
1632
+ * @param bbox 块的边界框 [x1, y1, x2, y2](相对于该页面的坐标)
1468
1633
  * @param shouldScroll 是否应该滚动到元素位置
1469
1634
  * @returns 是否成功找到并高亮了元素
1470
1635
  */
1471
1636
  const highlightPosition = (
1637
+ pageNum: number,
1472
1638
  bbox: [number, number, number, number],
1473
1639
  shouldScroll: boolean = true
1474
1640
  ): boolean => {
1475
1641
  // 清除之前的高亮
1476
1642
  if (activeBlockDiv.value) {
1477
- activeBlockDiv.value.style.backgroundColor = 'transparent';
1478
- activeBlockDiv.value.style.boxShadow = 'none';
1643
+ activeBlockDiv.value.style.backgroundColor = "transparent";
1644
+ activeBlockDiv.value.style.boxShadow = "none";
1479
1645
  activeBlockDiv.value = null;
1480
1646
  }
1481
1647
  isHighlighted.value = false;
1482
1648
 
1483
- const textLayer = textLayerRef.value;
1649
+ // 如果页码不在有效范围内,返回 false
1650
+ if (pageNum < 1 || pageNum > totalPages.value) {
1651
+ return false;
1652
+ }
1653
+
1654
+ // 在指定页面的文本图层中查找
1655
+ const textLayer = textLayerRefs.get(pageNum);
1484
1656
  if (!textLayer) return false;
1485
1657
 
1486
- // 查找对应的块元素,使用存储的bbox数据匹配(使用容差)
1487
- const blockDivs = textLayer.querySelectorAll('.text-block');
1488
1658
  const tolerance = 2;
1489
-
1490
1659
  let matchedElement: HTMLElement | null = null;
1491
1660
 
1661
+ // 查找对应的块元素,使用存储的bbox数据匹配(使用容差)
1662
+ const blockDivs = textLayer.querySelectorAll(".text-block");
1663
+
1492
1664
  blockDivs.forEach((div) => {
1493
1665
  const el = div as HTMLElement;
1494
1666
  const storedBbox = el.dataset.bbox;
1495
1667
  if (!storedBbox) return;
1496
1668
 
1497
1669
  try {
1498
- const parsedBbox = JSON.parse(storedBbox) as [number, number, number, number];
1670
+ const parsedBbox = JSON.parse(storedBbox) as [
1671
+ number,
1672
+ number,
1673
+ number,
1674
+ number
1675
+ ];
1499
1676
 
1500
1677
  const isMatch =
1501
1678
  Math.abs(parsedBbox[0] - bbox[0]) < tolerance &&
@@ -1507,35 +1684,48 @@ const highlightPosition = (
1507
1684
  matchedElement = el;
1508
1685
  }
1509
1686
  } catch (error) {
1510
- console.warn('解析bbox失败:', error);
1687
+ console.warn("解析bbox失败:", error);
1511
1688
  }
1512
1689
  });
1513
1690
 
1514
1691
  if (!matchedElement) return false;
1515
1692
 
1693
+ // 如果找到的元素不在当前页,切换到对应页面
1694
+ if (pageNum !== currentPage.value) {
1695
+ switchToPage(pageNum);
1696
+ // 等待页面切换完成后再高亮
1697
+ nextTick(() => {
1698
+ setTimeout(() => {
1699
+ highlightPosition(pageNum, bbox, shouldScroll);
1700
+ }, 300);
1701
+ });
1702
+ return true;
1703
+ }
1704
+
1516
1705
  // 保存引用
1517
- const elementRef = matchedElement;
1706
+ const elementRef = matchedElement as HTMLElement;
1518
1707
 
1519
1708
  activeBlockDiv.value = elementRef;
1520
1709
  isHighlighted.value = true;
1521
1710
 
1522
1711
  // 使用一致的高亮样式
1523
- elementRef.style.backgroundColor = 'var(--s-color-brand-primary-transparent-3, rgba(0, 102, 255, .15))';
1524
- elementRef.style.boxShadow = '0 0 0 2px rgba(30, 144, 255, 0.6)';
1712
+ elementRef.style.backgroundColor =
1713
+ "var(--s-color-brand-primary-transparent-3, rgba(0, 102, 255, .15))";
1714
+ elementRef.style.boxShadow = "0 0 0 2px rgba(30, 144, 255, 0.6)";
1525
1715
 
1526
1716
  // 只有在需要滚动且元素不在视口内时才滚动
1527
1717
  if (shouldScroll && containerRef.value) {
1528
1718
  const isVisible = isElementVisible(elementRef, containerRef.value);
1529
1719
  if (!isVisible) {
1530
- elementRef.scrollIntoView({ behavior: 'smooth', block: 'center' });
1720
+ elementRef.scrollIntoView({ behavior: "smooth", block: "center" });
1531
1721
  }
1532
1722
  }
1533
1723
 
1534
1724
  // 5秒后自动取消高亮
1535
1725
  setTimeout(() => {
1536
1726
  if (activeBlockDiv.value === elementRef && isHighlighted.value) {
1537
- elementRef.style.backgroundColor = 'transparent';
1538
- elementRef.style.boxShadow = 'none';
1727
+ elementRef.style.backgroundColor = "transparent";
1728
+ elementRef.style.boxShadow = "none";
1539
1729
  activeBlockDiv.value = null;
1540
1730
  isHighlighted.value = false;
1541
1731
  }
@@ -1546,13 +1736,21 @@ const highlightPosition = (
1546
1736
 
1547
1737
  /**
1548
1738
  * 跳转到指定位置并高亮(外部调用接口)
1549
- * @param bbox 位置的边界框 [x1, y1, x2, y2]
1739
+ * @param pageNum 页码(必须)
1740
+ * @param bbox 位置的边界框 [x1, y1, x2, y2](相对于该页面的坐标)
1550
1741
  * @param emitEvent 是否触发跳转事件,默认为 true
1551
1742
  */
1552
1743
  const jumpToPosition = (
1744
+ pageNum: number,
1553
1745
  bbox: [number, number, number, number],
1554
1746
  emitEvent: boolean = true
1555
1747
  ) => {
1748
+ // 如果页码不在有效范围内,直接返回
1749
+ if (pageNum < 1 || pageNum > totalPages.value) {
1750
+ console.warn(`页码 ${pageNum} 不在有效范围内 (1-${totalPages.value})`);
1751
+ return;
1752
+ }
1753
+
1556
1754
  // 等待DOM更新后再高亮
1557
1755
  nextTick(() => {
1558
1756
  let retryCount = 0;
@@ -1560,17 +1758,17 @@ const jumpToPosition = (
1560
1758
  const retryDelay = 200;
1561
1759
 
1562
1760
  const tryHighlight = () => {
1563
- const success = highlightPosition(bbox, true);
1761
+ const success = highlightPosition(pageNum, bbox, true);
1564
1762
  if (success) {
1565
1763
  // 高亮成功,触发事件
1566
1764
  if (emitEvent) {
1567
- emit('position-jump', bbox, 'block');
1765
+ emit("position-jump", bbox, "block");
1568
1766
  }
1569
1767
  } else if (retryCount < maxRetries) {
1570
1768
  retryCount++;
1571
1769
  setTimeout(tryHighlight, retryDelay);
1572
1770
  } else {
1573
- console.warn(`无法找到并高亮指定位置: bbox: [${bbox.join(', ')}]`);
1771
+ console.warn(`无法找到并高亮指定位置: 页码 ${pageNum}, bbox: [${bbox.join(", ")}]`);
1574
1772
  }
1575
1773
  };
1576
1774
 
@@ -1647,10 +1845,14 @@ watch(
1647
1845
  /**
1648
1846
  * 监听缩放、旋转、位置变化,更新文本图层位置
1649
1847
  */
1650
- watch([scale, rotation, position], () => {
1651
- // 文本图层会跟随图片的 transform,不需要手动更新位置
1652
- // 但需要确保文本图层与图片同步变换
1653
- }, { deep: true });
1848
+ watch(
1849
+ [scale, rotation, position],
1850
+ () => {
1851
+ // 文本图层会跟随图片的 transform,不需要手动更新位置
1852
+ // 但需要确保文本图层与图片同步变换
1853
+ },
1854
+ { deep: true }
1855
+ );
1654
1856
 
1655
1857
  /**
1656
1858
  * 组件挂载时的初始化
@@ -1690,12 +1892,15 @@ defineExpose({
1690
1892
  jumpToPosition, // 暴露定位方法给父组件
1691
1893
  goToPage: switchToPage, // 暴露翻页方法给父组件
1692
1894
  getCurrentPage: () => currentPage.value, // 获取当前页码
1693
- getTotalPages: () => totalPages.value // 获取总页数
1895
+ getTotalPages: () => totalPages.value, // 获取总页数
1694
1896
  });
1695
1897
  </script>
1696
1898
 
1697
1899
  <style lang="less" scoped>
1698
1900
  // 样式已统一到公共样式文件,这里只保留组件特定样式
1901
+ .toolbar-group {
1902
+ gap: 0 !important;
1903
+ }
1699
1904
 
1700
1905
  // 图片容器
1701
1906
  .image-container {
@@ -1719,8 +1924,8 @@ defineExpose({
1719
1924
  // 图片包装器容器(用于居中显示和提供滚动空间)
1720
1925
  .image-wrapper-container {
1721
1926
  display: flex;
1722
- align-items: flex-start; // 改为顶部对齐,避免垂直居中产生间隙
1723
- justify-content: center;
1927
+ flex-direction: column; // 垂直排列所有页面
1928
+ align-items: center; // 水平居中
1724
1929
  width: 100%;
1725
1930
  min-height: 100%;
1726
1931
  box-sizing: border-box;
@@ -1734,6 +1939,17 @@ defineExpose({
1734
1939
  padding: 0;
1735
1940
  }
1736
1941
 
1942
+ // 每个页面的容器
1943
+ .image-page-container {
1944
+ position: relative;
1945
+ display: flex;
1946
+ justify-content: center;
1947
+ width: 100%;
1948
+ flex-shrink: 0;
1949
+ margin: 0;
1950
+ padding: 0;
1951
+ }
1952
+
1737
1953
  // 图片包装器(包含图片和文本图层)
1738
1954
  .image-wrapper {
1739
1955
  position: relative;
@@ -1747,7 +1963,7 @@ defineExpose({
1747
1963
  margin: 0;
1748
1964
  padding: 0;
1749
1965
  line-height: 0;
1750
-
1966
+
1751
1967
  img {
1752
1968
  display: block; // 移除图片底部默认间隙
1753
1969
  margin: 0;
@@ -1808,7 +2024,7 @@ defineExpose({
1808
2024
  border-radius: 6px;
1809
2025
  box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
1810
2026
  animation: fade-in 0.2s ease;
1811
-
2027
+
1812
2028
  .annotation-button-action {
1813
2029
  display: flex;
1814
2030
  flex-direction: row;
@@ -1838,20 +2054,20 @@ defineExpose({
1838
2054
  box-shadow: 0 4px 16px rgb(0 0 0 / 20%);
1839
2055
  animation: fade-in 0.2s ease;
1840
2056
  overflow: hidden;
1841
-
2057
+
1842
2058
  .annotation-input-header {
1843
2059
  display: flex;
1844
2060
  align-items: center;
1845
2061
  justify-content: space-between;
1846
2062
  padding: 12px 16px;
1847
2063
  border-bottom: 1px solid #e5e7eb;
1848
-
2064
+
1849
2065
  .annotation-input-title {
1850
2066
  font-size: 14px;
1851
2067
  font-weight: 500;
1852
2068
  color: #1d2129;
1853
2069
  }
1854
-
2070
+
1855
2071
  .annotation-close-btn {
1856
2072
  display: flex;
1857
2073
  align-items: center;
@@ -1865,22 +2081,22 @@ defineExpose({
1865
2081
  border-radius: 4px;
1866
2082
  color: #86909c;
1867
2083
  transition: all 0.2s;
1868
-
2084
+
1869
2085
  &:hover {
1870
2086
  background-color: #f0f0f0;
1871
2087
  color: #1d2129;
1872
2088
  }
1873
2089
  }
1874
2090
  }
1875
-
2091
+
1876
2092
  .annotation-input-content {
1877
2093
  padding: 12px 16px;
1878
-
2094
+
1879
2095
  .annotation-textarea {
1880
2096
  width: 100%;
1881
2097
  }
1882
2098
  }
1883
-
2099
+
1884
2100
  .annotation-input-footer {
1885
2101
  display: flex;
1886
2102
  align-items: center;
@@ -1903,17 +2119,17 @@ defineExpose({
1903
2119
  padding: 1px 3px;
1904
2120
  // 添加阴影效果
1905
2121
  box-shadow: 0 1px 2px rgba(255, 193, 7, 0.25) !important;
1906
-
2122
+
1907
2123
  // 鼠标悬停时加深效果
1908
2124
  &:hover {
1909
2125
  background-color: rgba(255, 243, 205, 0.7) !important;
1910
2126
  border-color: rgba(255, 193, 7, 0.9) !important;
1911
2127
  box-shadow: 0 2px 4px rgba(255, 193, 7, 0.35) !important;
1912
2128
  }
1913
-
2129
+
1914
2130
  // 使用伪元素添加批注标记图标
1915
2131
  &::before {
1916
- content: '📝';
2132
+ content: "📝";
1917
2133
  position: absolute;
1918
2134
  top: -6px;
1919
2135
  right: -6px;