@koi-br/ocr-web-sdk 1.0.27 → 1.0.29

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,20 +388,56 @@ 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
  // 计算图片放大后的实际尺寸(考虑旋转)
429
+ // 在自适应宽度模式下,使用第一页的尺寸;否则使用当前页的尺寸
368
430
  const scaledImageSize = computed(() => {
369
- if (imageSize.value.width === 0 || imageSize.value.height === 0) {
431
+ // 如果启用自适应宽度,使用第一页的尺寸作为基准
432
+ const baseSize = props.autoFitWidth
433
+ ? (imageSizes.get(1) || { width: 0, height: 0 })
434
+ : imageSize.value;
435
+
436
+ if (baseSize.width === 0 || baseSize.height === 0) {
370
437
  return { width: 0, height: 0 };
371
438
  }
372
439
 
373
- const { width, height } = imageSize.value;
440
+ const { width, height } = baseSize;
374
441
  const scaledWidth = width * scale.value;
375
442
  const scaledHeight = height * scale.value;
376
443
 
@@ -380,7 +447,7 @@ const scaledImageSize = computed(() => {
380
447
 
381
448
  return {
382
449
  width: isRotated ? scaledHeight : scaledWidth,
383
- height: isRotated ? scaledWidth : scaledHeight
450
+ height: isRotated ? scaledWidth : scaledHeight,
384
451
  };
385
452
  });
386
453
 
@@ -392,23 +459,26 @@ const containerStyle = computed(() => {
392
459
  // 让容器自适应内容,避免横向滚动条
393
460
  if (props.autoFitWidth) {
394
461
  return {
395
- minWidth: '100%', // 使用 100% 让容器自适应父容器宽度
396
- minHeight: height > 0 ? `${height}px` : '100%'
462
+ minWidth: "100%", // 使用 100% 让容器自适应父容器宽度
463
+ minHeight: height > 0 ? `${height}px` : "100%",
397
464
  };
398
465
  }
399
466
 
400
467
  // 未启用自适应宽度时,使用原始逻辑
401
468
  return {
402
- minWidth: width > 0 ? `${width}px` : '100%',
403
- minHeight: height > 0 ? `${height}px` : '100%'
469
+ minWidth: width > 0 ? `${width}px` : "100%",
470
+ minHeight: height > 0 ? `${height}px` : "100%",
404
471
  };
405
472
  });
406
473
 
407
474
  // 批注相关状态
408
475
  const showAnnotationPopup = ref(false); // 是否显示批注弹窗
409
476
  const annotationPopupStyle = ref<any>({}); // 批注弹窗样式
410
- const annotationInput = ref(''); // 批注输入内容
411
- const currentAnnotationBlock = ref<{ bbox: [number, number, number, number]; content: string } | null>(null); // 当前正在添加批注的文本块
477
+ const annotationInput = ref(""); // 批注输入内容
478
+ const currentAnnotationBlock = ref<{
479
+ bbox: [number, number, number, number];
480
+ content: string;
481
+ } | null>(null); // 当前正在添加批注的文本块
412
482
  const annotationPopupRef = ref<HTMLElement>(); // 批注弹窗引用
413
483
 
414
484
  // 文本选择相关状态(保留用于兼容)
@@ -421,14 +491,22 @@ const currentPageBlocksData = computed(() => {
421
491
  return [];
422
492
  }
423
493
  // 根据当前页码过滤
424
- return props.blocksData.filter(block => block.pageNo === currentPage.value);
494
+ return props.blocksData.filter((block) => block.pageNo === currentPage.value);
425
495
  });
426
496
 
497
+ // 获取指定页码的blocksData
498
+ const getPageBlocksData = (pageNo: number) => {
499
+ if (!props.blocksData || props.blocksData.length === 0) {
500
+ return [];
501
+ }
502
+ return props.blocksData.filter((block) => block.pageNo === pageNo);
503
+ };
504
+
427
505
  // 隐藏定时器
428
506
  let hideTimer: any = null;
429
507
 
430
- const rotateImage = direction => {
431
- if (direction === 'left') {
508
+ const rotateImage = (direction) => {
509
+ if (direction === "left") {
432
510
  rotation.value = (rotation.value - 90) % 360;
433
511
  } else {
434
512
  rotation.value = (rotation.value + 90) % 360;
@@ -438,7 +516,7 @@ const rotateImage = direction => {
438
516
  const zoom = (delta, isWheel = false) => {
439
517
  // 用户主动缩放时,标记为手动缩放
440
518
  isUserZooming.value = true;
441
-
519
+
442
520
  const step = isWheel ? props.wheelStep : props.clickStep; // 滚轮缩放使用更小的步长
443
521
  const newScale = scale.value + delta * step;
444
522
  if (newScale <= props.minScale) {
@@ -464,7 +542,7 @@ const handleWheel = (e: WheelEvent) => {
464
542
  const startPan = (e: MouseEvent) => {
465
543
  // 只在左键点击时启用拖拽
466
544
  if (e.button !== 0) return;
467
-
545
+
468
546
  // 如果有文本图层数据,默认允许文本选择,不拖拽
469
547
  // 只有在按住 Ctrl 或 Meta 键时才允许拖拽
470
548
  if (currentPageBlocksData.value && currentPageBlocksData.value.length > 0) {
@@ -478,7 +556,7 @@ const startPan = (e: MouseEvent) => {
478
556
  return;
479
557
  }
480
558
  }
481
-
559
+
482
560
  isPanning.value = true;
483
561
  lastPosition.value = { x: e.clientX, y: e.clientY };
484
562
  e.preventDefault(); // 阻止默认行为,避免与滚动冲突
@@ -492,7 +570,7 @@ const pan = (e: MouseEvent) => {
492
570
 
493
571
  position.value = {
494
572
  x: position.value.x + deltaX,
495
- y: position.value.y + deltaY
573
+ y: position.value.y + deltaY,
496
574
  };
497
575
 
498
576
  lastPosition.value = { x: e.clientX, y: e.clientY };
@@ -501,23 +579,35 @@ const pan = (e: MouseEvent) => {
501
579
 
502
580
  const stopPan = () => {
503
581
  isPanning.value = false;
504
-
582
+
505
583
  // 在自适应宽度模式下,如果用户没有主动缩放,确保缩放比例保持初始值
506
- if (props.autoFitWidth &&
507
- !isUserZooming.value &&
508
- initialAutoFitScale.value !== null &&
509
- Math.abs(scale.value - initialAutoFitScale.value) > 0.01) {
584
+ if (
585
+ props.autoFitWidth &&
586
+ !isUserZooming.value &&
587
+ initialAutoFitScale.value !== null &&
588
+ Math.abs(scale.value - initialAutoFitScale.value) > 0.01
589
+ ) {
510
590
  // 拖拽操作不应该改变缩放比例,如果被改变了,恢复它
511
591
  scale.value = initialAutoFitScale.value;
512
592
  }
593
+ };
513
594
 
595
+ // 处理图片鼠标按下事件(允许文本选择)
596
+ const handleImageMouseDown = (e: MouseEvent) => {
597
+ // 如果点击的是文本块,不阻止默认行为,允许文本选择
598
+ const target = e.target as HTMLElement;
599
+ if (target.closest(".text-block")) {
600
+ return; // 允许文本选择
601
+ }
602
+ // 否则阻止默认行为,允许拖拽
603
+ e.preventDefault();
514
604
  };
515
605
 
516
606
  // 处理鼠标抬起事件(检测文本选择)
517
607
  const handleMouseUp = (e: MouseEvent) => {
518
608
  // 先执行停止拖拽的逻辑
519
609
  stopPan();
520
-
610
+
521
611
  // 延迟检测文本选择,确保选择已完成
522
612
  setTimeout(() => {
523
613
  checkTextSelection(e);
@@ -530,19 +620,25 @@ const checkTextSelection = (e: MouseEvent) => {
530
620
  if (currentAnnotationBlock.value) {
531
621
  return;
532
622
  }
533
-
623
+
534
624
  const target = e.target as HTMLElement;
535
625
  // 如果点击的是批注按钮或批注弹窗,不执行文本选择检测
536
- if (target.closest('.annotation-button-popup') || target.closest('.annotation-input-popup')) {
626
+ if (
627
+ target.closest(".annotation-button-popup") ||
628
+ target.closest(".annotation-input-popup")
629
+ ) {
537
630
  return;
538
631
  }
539
-
632
+
540
633
  const selection = window.getSelection();
541
-
634
+
542
635
  // 如果没有选中文本,隐藏批注按钮(但不关闭批注输入弹窗)
543
636
  if (!selection || selection.toString().trim().length === 0) {
544
637
  // 如果点击的不是文本块,且不在批注弹窗内,隐藏批注按钮
545
- if (!target.closest('.text-block') && !target.closest('.annotation-input-popup')) {
638
+ if (
639
+ !target.closest(".text-block") &&
640
+ !target.closest(".annotation-input-popup")
641
+ ) {
546
642
  // 只有在没有打开批注输入弹窗时才隐藏批注按钮
547
643
  if (!currentAnnotationBlock.value) {
548
644
  showAnnotationPopup.value = false;
@@ -551,92 +647,100 @@ const checkTextSelection = (e: MouseEvent) => {
551
647
  }
552
648
  return;
553
649
  }
554
-
650
+
555
651
  // 获取选中的文本
556
652
  const selectedText = selection.toString().trim();
557
653
  if (!selectedText) {
558
654
  return;
559
655
  }
560
-
656
+
561
657
  // 检查选中的文本是否在文本块内
562
658
  const range = selection.getRangeAt(0);
563
659
  const container = range.commonAncestorContainer;
564
-
660
+
565
661
  // 向上查找最近的文本块元素
566
662
  let textBlockElement: HTMLElement | null = null;
567
- let node: Node | null = container.nodeType === Node.TEXT_NODE ? container.parentElement : container as HTMLElement;
568
-
663
+ let node: Node | null =
664
+ container.nodeType === Node.TEXT_NODE
665
+ ? container.parentElement
666
+ : (container as HTMLElement);
667
+
569
668
  while (node && node !== document.body) {
570
- if (node instanceof HTMLElement && node.classList.contains('text-block')) {
669
+ if (node instanceof HTMLElement && node.classList.contains("text-block")) {
571
670
  textBlockElement = node;
572
671
  break;
573
672
  }
574
673
  node = node.parentElement;
575
674
  }
576
-
675
+
577
676
  if (!textBlockElement) {
578
677
  return;
579
678
  }
580
-
679
+
581
680
  // 设置当前激活的文本块
582
681
  activeBlockDiv.value = textBlockElement;
583
-
682
+
584
683
  // 获取选中文本的位置
585
684
  const rect = range.getBoundingClientRect();
586
685
  const containerRect = containerRef.value?.getBoundingClientRect();
587
-
686
+
588
687
  if (!containerRect || !containerRef.value) return;
589
-
688
+
590
689
  // 计算批注按钮的位置
591
690
  const buttonWidth = 80;
592
691
  const popupWidth = buttonWidth + 8;
593
692
  const popupHeight = 38;
594
693
  const spacing = 4; // 减少间距,让按钮更靠近文字
595
-
694
+
596
695
  // 计算相对于容器的坐标(考虑滚动)
597
- const relativeX = rect.left - containerRect.left + containerRef.value.scrollLeft;
696
+ const relativeX =
697
+ rect.left - containerRect.left + containerRef.value.scrollLeft;
598
698
  const relativeY = rect.top - containerRect.top + containerRef.value.scrollTop;
599
-
699
+
600
700
  // 容器可用空间
601
701
  const containerWidth = containerRef.value.clientWidth;
602
-
702
+
603
703
  // 计算可用空间
604
704
  const rectBottom = rect.bottom;
605
705
  const rectTop = rect.top;
606
706
  const containerBottom = containerRect.bottom;
607
707
  const containerTop = containerRect.top;
608
-
708
+
609
709
  const bottomSpace = containerBottom - rectBottom;
610
710
  const topSpace = rectTop - containerTop;
611
-
612
- const showOnBottom = bottomSpace >= popupHeight + spacing || (bottomSpace > 0 && bottomSpace >= topSpace);
613
-
711
+
712
+ const showOnBottom =
713
+ bottomSpace >= popupHeight + spacing ||
714
+ (bottomSpace > 0 && bottomSpace >= topSpace);
715
+
614
716
  // 计算最终位置
615
717
  let left: number;
616
718
  let top: number;
617
-
719
+
618
720
  const centerX = relativeX + rect.width / 2;
619
721
  left = centerX - popupWidth / 2;
620
-
722
+
621
723
  if (showOnBottom) {
622
724
  top = relativeY + rect.height + spacing;
623
725
  } else {
624
726
  top = relativeY - popupHeight - spacing;
625
727
  }
626
-
728
+
627
729
  // 边界检查
628
- const popupTopInViewport = top - containerRef.value.scrollTop + containerRect.top;
730
+ const popupTopInViewport =
731
+ top - containerRef.value.scrollTop + containerRect.top;
629
732
  const popupBottomInViewport = popupTopInViewport + popupHeight;
630
-
733
+
631
734
  if (popupBottomInViewport > containerBottom - spacing) {
632
735
  top = relativeY - popupHeight - spacing;
633
736
  }
634
-
635
- const newPopupTopInViewport = top - containerRef.value.scrollTop + containerRect.top;
737
+
738
+ const newPopupTopInViewport =
739
+ top - containerRef.value.scrollTop + containerRect.top;
636
740
  if (newPopupTopInViewport < containerTop + spacing) {
637
741
  top = relativeY + rect.height + spacing;
638
742
  }
639
-
743
+
640
744
  // 确保不超出容器边界
641
745
  if (left + popupWidth > containerWidth - spacing) {
642
746
  left = containerWidth - popupWidth - spacing;
@@ -644,12 +748,12 @@ const checkTextSelection = (e: MouseEvent) => {
644
748
  if (left < spacing) {
645
749
  left = spacing;
646
750
  }
647
-
751
+
648
752
  annotationPopupStyle.value = {
649
753
  left: `${left}px`,
650
- top: `${top}px`
754
+ top: `${top}px`,
651
755
  };
652
-
756
+
653
757
  // 显示批注按钮
654
758
  showAnnotationPopup.value = true;
655
759
  };
@@ -671,53 +775,38 @@ const switchToPage = (page: number) => {
671
775
  if (page < 1 || page > totalPages.value) {
672
776
  return;
673
777
  }
674
-
675
- // 切换页码前重置状态(但不重置缩放,等图片加载后再设置)
676
- rotation.value = 0;
677
- position.value = { x: 0, y: 0 };
678
- isUserZooming.value = false;
679
-
778
+
680
779
  // 重置文本选择状态和批注状态
681
780
  showAnnotationPopup.value = false;
682
781
  currentAnnotationBlock.value = null;
683
- annotationInput.value = '';
782
+ annotationInput.value = "";
684
783
  activeBlockDiv.value = null;
685
784
  isHighlighted.value = false;
686
-
687
- // 先更新页码,这样图片会立即切换
785
+
786
+ // 更新页码
688
787
  currentPage.value = page;
689
-
788
+
690
789
  // 触发页码变化事件
691
- emit('page-change', page, totalPages.value);
692
-
693
- // 确保滚动位置在顶部(在滚动翻页时已经设置,这里作为保险)
790
+ emit("page-change", page, totalPages.value);
791
+
792
+ // 滚动到对应页面
694
793
  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();
794
+ const pageElement = containerRef.value.querySelector(
795
+ `[data-page-number="${page}"]`
796
+ ) as HTMLElement;
797
+ if (pageElement) {
798
+ pageElement.scrollIntoView({ behavior: "smooth", block: "start" });
799
+ // 更新 lastScrollTop,确保滚动方向判断准确
800
+ nextTick(() => {
801
+ lastScrollTop = containerRef.value?.scrollTop || 0;
802
+ });
714
803
  }
715
- });
804
+ }
716
805
  };
717
806
 
718
807
  const reset = () => {
719
808
  rotation.value = 0;
720
-
809
+
721
810
  // 如果启用自适应宽度且有初始缩放比例,恢复到初始缩放比例
722
811
  if (props.autoFitWidth && initialAutoFitScale.value !== null) {
723
812
  scale.value = initialAutoFitScale.value;
@@ -725,24 +814,30 @@ const reset = () => {
725
814
  } else {
726
815
  scale.value = 1;
727
816
  }
728
-
817
+
729
818
  position.value = { x: 0, y: 0 };
730
-
819
+
731
820
  // 重置文本选择状态和批注状态
732
821
  showAnnotationPopup.value = false;
733
822
  currentAnnotationBlock.value = null;
734
- annotationInput.value = '';
823
+ annotationInput.value = "";
735
824
  activeBlockDiv.value = null;
736
825
  isHighlighted.value = false;
737
826
  };
738
827
 
739
828
  const original = () => {
740
- emit('original');
829
+ emit("original");
741
830
  };
742
831
 
743
832
  // 计算自适应宽度的缩放比例
744
833
  const calculateAutoFitScale = () => {
745
- if (!props.autoFitWidth || !containerRef.value || imageSize.value.width === 0) {
834
+ if (!props.autoFitWidth || !containerRef.value) {
835
+ return 1;
836
+ }
837
+
838
+ // 使用第一页的图片尺寸作为基准(所有页面使用相同的缩放比例)
839
+ const firstPageSize = imageSizes.get(1);
840
+ if (!firstPageSize || firstPageSize.width === 0) {
746
841
  return 1;
747
842
  }
748
843
 
@@ -750,7 +845,7 @@ const calculateAutoFitScale = () => {
750
845
  // 使用容器的完整宽度,但预留足够空间避免因浏览器渲染误差导致横向滚动条
751
846
  // 预留 4px 以确保不会因为任何渲染误差导致滚动条
752
847
  const containerWidth = containerRect.width - 4;
753
-
848
+
754
849
  if (containerWidth <= 0) {
755
850
  return 1;
756
851
  }
@@ -758,33 +853,35 @@ const calculateAutoFitScale = () => {
758
853
  // 考虑旋转角度,如果旋转了90度或270度,需要交换宽高
759
854
  const normalizedRotation = ((rotation.value % 360) + 360) % 360;
760
855
  const isRotated = normalizedRotation === 90 || normalizedRotation === 270;
761
-
762
- const imageWidth = isRotated ? imageSize.value.height : imageSize.value.width;
763
-
856
+
857
+ const imageWidth = isRotated ? firstPageSize.height : firstPageSize.width;
858
+
764
859
  if (imageWidth <= 0) {
765
860
  return 1;
766
861
  }
767
-
862
+
768
863
  // 计算缩放比例,使图片宽度完全适应容器宽度
769
864
  const calculatedScale = containerWidth / imageWidth;
770
-
865
+
771
866
  // 确保缩放比例在允许的范围内
772
867
  return Math.max(props.minScale, Math.min(props.maxScale, calculatedScale));
773
868
  };
774
869
 
775
870
  // 图片加载完成处理
776
- const onImageLoad = (event: Event) => {
871
+ const onImageLoad = (event: Event, pageNum: number) => {
777
872
  const img = event.target as HTMLImageElement;
778
- imageSize.value = {
779
- width: img.naturalWidth,
780
- height: img.naturalHeight
781
- };
782
873
 
783
- // 如果启用自适应宽度,计算并设置初始缩放比例
784
- if (props.autoFitWidth) {
874
+ // 存储该页的图片尺寸
875
+ imageSizes.set(pageNum, {
876
+ width: img.naturalWidth,
877
+ height: img.naturalHeight,
878
+ });
879
+
880
+ // 如果是第一页且启用自适应宽度,计算并设置初始缩放比例
881
+ if (pageNum === 1 && props.autoFitWidth) {
785
882
  // 重置用户缩放标记
786
883
  isUserZooming.value = false;
787
-
884
+
788
885
  // 使用双重 nextTick 确保容器尺寸已确定
789
886
  nextTick(() => {
790
887
  nextTick(() => {
@@ -795,21 +892,29 @@ const onImageLoad = (event: Event) => {
795
892
  scale.value = autoScale;
796
893
  initialAutoFitScale.value = autoScale; // 记录初始自适应缩放比例
797
894
  }
798
- }, 50);
895
+ }, 100); // 增加延迟,确保所有图片都已加载
799
896
  });
800
897
  });
801
898
  }
802
899
 
803
- emit('load', {
900
+ // 如果第一页已经加载完成,且当前页不是第一页,也应用自适应宽度
901
+ if (pageNum > 1 && props.autoFitWidth && initialAutoFitScale.value !== null) {
902
+ // 确保后续页面也使用相同的缩放比例
903
+ if (Math.abs(scale.value - initialAutoFitScale.value) > 0.01) {
904
+ scale.value = initialAutoFitScale.value;
905
+ }
906
+ }
907
+
908
+ emit("load", {
804
909
  width: img.naturalWidth,
805
910
  height: img.naturalHeight,
806
- page: currentPage.value,
807
- totalPages: totalPages.value
911
+ page: pageNum,
912
+ totalPages: totalPages.value,
808
913
  });
809
-
810
- // 图片加载完成后,渲染文本图层
914
+
915
+ // 图片加载完成后,渲染该页的文本图层
811
916
  nextTick(() => {
812
- renderTextLayer();
917
+ renderTextLayer(pageNum);
813
918
  });
814
919
  };
815
920
 
@@ -825,7 +930,7 @@ const onImageLoad = (event: Event) => {
825
930
  const calculateFontSize = (
826
931
  text: string,
827
932
  targetWidth: number,
828
- targetHeight: number,
933
+ targetHeight: number
829
934
  ): number => {
830
935
  // 创建一个离屏 Canvas 用于测量文本
831
936
  const canvas = document.createElement("canvas");
@@ -870,34 +975,50 @@ const calculateFontSize = (
870
975
  };
871
976
 
872
977
  /**
873
- * 渲染文本图层(使用 blocksData 数据)
978
+ * 渲染文本图层(使用 blocksData 数据,支持增量渲染)
874
979
  */
875
- const renderTextLayer = () => {
876
- const textLayer = textLayerRef.value;
877
- const image = imageRef.value;
980
+ const renderTextLayer = (pageNum?: number) => {
981
+ const targetPage = pageNum || currentPage.value;
982
+ const textLayer = textLayerRefs.get(targetPage);
983
+ const image = imageRefs.get(targetPage);
878
984
 
879
985
  if (!textLayer || !image) {
880
986
  return;
881
987
  }
882
988
 
883
- console.log('renderTextLayer', currentPageBlocksData.value);
989
+ // 如果图片还没加载完成,等待加载完成后再渲染
990
+ if (!image.complete || image.naturalWidth === 0) {
991
+ return;
992
+ }
993
+
994
+ const pageBlocksData = getPageBlocksData(targetPage);
995
+ console.log("renderTextLayer", targetPage, pageBlocksData);
884
996
 
885
- // 如果没有提供分块数据,跳过渲染
886
- if (!currentPageBlocksData.value || currentPageBlocksData.value.length === 0) {
887
- textLayer.innerHTML = '';
997
+ // 设置文本图层的尺寸与图片尺寸一致
998
+ textLayer.style.width = `${image.naturalWidth}px`;
999
+ textLayer.style.height = `${image.naturalHeight}px`;
1000
+
1001
+ // 如果没有提供分块数据,清空文本图层
1002
+ if (!pageBlocksData || pageBlocksData.length === 0) {
1003
+ textLayer.innerHTML = "";
888
1004
  return;
889
1005
  }
890
1006
 
891
1007
  try {
892
- // 设置文本图层的尺寸与图片尺寸一致
893
- textLayer.style.width = `${image.naturalWidth}px`;
894
- textLayer.style.height = `${image.naturalHeight}px`;
1008
+ // 增量渲染:获取现有的文本块
1009
+ const existingBlocks = new Map<string, HTMLElement>();
1010
+ textLayer.querySelectorAll(".text-block").forEach((el) => {
1011
+ const bboxStr = (el as HTMLElement).dataset.bbox;
1012
+ if (bboxStr) {
1013
+ existingBlocks.set(bboxStr, el as HTMLElement);
1014
+ }
1015
+ });
895
1016
 
896
- // 清空文本图层
897
- textLayer.innerHTML = '';
1017
+ // 创建新的文本块 Map,用于跟踪需要保留的块
1018
+ const newBlocksMap = new Map<string, boolean>();
898
1019
 
899
- // 使用当前页码的 blocksData 创建可交互的块
900
- currentPageBlocksData.value.forEach((block, index) => {
1020
+ // 使用指定页码的 blocksData 创建或更新可交互的块
1021
+ pageBlocksData.forEach((block, index) => {
901
1022
  const { content, bbox } = block;
902
1023
 
903
1024
  // bbox 格式: [x1, y1, x2, y2]
@@ -909,40 +1030,40 @@ const renderTextLayer = () => {
909
1030
  const calculatedFontSize = calculateFontSize(content, width, height);
910
1031
 
911
1032
  // 创建文本块
912
- const blockDiv = document.createElement('div');
913
- blockDiv.className = 'text-block';
1033
+ const blockDiv = document.createElement("div");
1034
+ blockDiv.className = "text-block";
914
1035
  blockDiv.dataset.text = content;
915
1036
  blockDiv.dataset.bbox = JSON.stringify(bbox);
916
1037
 
917
1038
  // 设置基础样式
918
- blockDiv.style.position = 'absolute';
1039
+ blockDiv.style.position = "absolute";
919
1040
  blockDiv.style.left = `${x1}px`;
920
1041
  blockDiv.style.top = `${y1}px`;
921
1042
  blockDiv.style.width = `${width}px`;
922
1043
  blockDiv.style.height = `${height}px`;
923
- blockDiv.style.zIndex = '20'; // 确保文本块在图片上方
924
- blockDiv.style.cursor = 'text'; // 改为文本选择光标
1044
+ blockDiv.style.zIndex = "20"; // 确保文本块在图片上方
1045
+ blockDiv.style.cursor = "text"; // 改为文本选择光标
925
1046
  // 设置文本内容(使用 textContent 而不是 innerHTML,避免 XSS 风险)
926
1047
  blockDiv.textContent = content;
927
1048
  // 设置文本样式,确保可以选择和显示
928
- blockDiv.style.color = 'red'; // 红色文字
1049
+ blockDiv.style.color = "red"; // 红色文字
929
1050
  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'; // 确保可见
1051
+ blockDiv.style.fontFamily = "Arial, sans-serif"; // 设置明确的字体
1052
+ blockDiv.style.lineHeight = "1.2"; // 设置合适的行高
1053
+ blockDiv.style.whiteSpace = "pre-wrap"; // 保留换行和空格
1054
+ blockDiv.style.overflow = "visible"; // 确保文字不被裁剪
1055
+ blockDiv.style.display = "block"; // 确保是块级元素
1056
+ blockDiv.style.visibility = "visible"; // 确保可见
936
1057
  // 允许文本选择
937
- blockDiv.style.userSelect = 'text';
938
- blockDiv.style.webkitUserSelect = 'text';
939
- blockDiv.style.mozUserSelect = 'text';
940
- blockDiv.style.msUserSelect = 'text';
1058
+ blockDiv.style.userSelect = "text";
1059
+ blockDiv.style.webkitUserSelect = "text";
1060
+ blockDiv.style.mozUserSelect = "text";
1061
+ blockDiv.style.msUserSelect = "text";
941
1062
  // 允许指针事件,但不阻止文本选择
942
- blockDiv.style.pointerEvents = 'auto';
1063
+ blockDiv.style.pointerEvents = "auto";
943
1064
 
944
1065
  // 右键菜单:显示批注按钮
945
- blockDiv.addEventListener('contextmenu', (e) => {
1066
+ blockDiv.addEventListener("contextmenu", (e) => {
946
1067
  e.preventDefault();
947
1068
  // 设置当前文本块为激活状态
948
1069
  activeBlockDiv.value = blockDiv;
@@ -954,78 +1075,155 @@ const renderTextLayer = () => {
954
1075
  const existingAnnotation = getAnnotationForBlock(bbox);
955
1076
  if (existingAnnotation) {
956
1077
  // 添加批注标记样式类
957
- blockDiv.classList.add('has-annotation');
1078
+ blockDiv.classList.add("has-annotation");
958
1079
  blockDiv.title = `已有批注: ${existingAnnotation.content}`;
959
1080
  // 直接设置样式,确保生效(内联样式优先级高于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
-
1081
+ blockDiv.style.backgroundColor = "rgba(255, 243, 205, 0.5)";
1082
+ blockDiv.style.border = "1px solid rgba(255, 193, 7, 0.7)";
1083
+ blockDiv.style.borderRadius = "3px";
1084
+ blockDiv.style.padding = "1px 3px";
1085
+ blockDiv.style.boxShadow = "0 1px 2px rgba(255, 193, 7, 0.25)";
1086
+
966
1087
  // 创建批注图标标记
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';
1088
+ const annotationMarker = document.createElement("span");
1089
+ annotationMarker.className = "annotation-marker";
1090
+ annotationMarker.textContent = "📝";
1091
+ annotationMarker.style.position = "absolute";
1092
+ annotationMarker.style.top = "-6px";
1093
+ annotationMarker.style.right = "-6px";
1094
+ annotationMarker.style.fontSize = "11px";
1095
+ annotationMarker.style.backgroundColor = "rgba(255, 193, 7, 0.95)";
1096
+ annotationMarker.style.borderRadius = "50%";
1097
+ annotationMarker.style.width = "16px";
1098
+ annotationMarker.style.height = "16px";
1099
+ annotationMarker.style.display = "flex";
1100
+ annotationMarker.style.alignItems = "center";
1101
+ annotationMarker.style.justifyContent = "center";
1102
+ annotationMarker.style.zIndex = "30";
1103
+ annotationMarker.style.boxShadow = "0 1px 3px rgba(0, 0, 0, 0.25)";
1104
+ annotationMarker.style.lineHeight = "1";
984
1105
  blockDiv.appendChild(annotationMarker);
985
1106
  } else {
986
1107
  // 没有批注时设置为透明背景
987
- blockDiv.style.backgroundColor = 'transparent';
1108
+ blockDiv.style.backgroundColor = "transparent";
1109
+ }
1110
+
1111
+ // 检查是否已存在相同的文本块(通过 bbox 匹配)
1112
+ const bboxKey = JSON.stringify(bbox);
1113
+ const existingBlock = existingBlocks.get(bboxKey);
1114
+
1115
+ if (existingBlock) {
1116
+ // 如果已存在,更新内容(增量更新,避免闪烁)
1117
+ existingBlock.textContent = content;
1118
+ existingBlock.dataset.text = content;
1119
+ // 更新批注状态
1120
+ const existingAnnotation = getAnnotationForBlock(bbox);
1121
+ if (existingAnnotation) {
1122
+ if (!existingBlock.classList.contains("has-annotation")) {
1123
+ existingBlock.classList.add("has-annotation");
1124
+ existingBlock.title = `已有批注: ${existingAnnotation.content}`;
1125
+ existingBlock.style.backgroundColor = "rgba(255, 243, 205, 0.5)";
1126
+ existingBlock.style.border = "1px solid rgba(255, 193, 7, 0.7)";
1127
+ existingBlock.style.borderRadius = "3px";
1128
+ existingBlock.style.padding = "1px 3px";
1129
+ existingBlock.style.boxShadow = "0 1px 2px rgba(255, 193, 7, 0.25)";
1130
+
1131
+ // 添加批注标记(如果还没有)
1132
+ if (!existingBlock.querySelector(".annotation-marker")) {
1133
+ const annotationMarker = document.createElement("span");
1134
+ annotationMarker.className = "annotation-marker";
1135
+ annotationMarker.textContent = "📝";
1136
+ annotationMarker.style.position = "absolute";
1137
+ annotationMarker.style.top = "-6px";
1138
+ annotationMarker.style.right = "-6px";
1139
+ annotationMarker.style.fontSize = "11px";
1140
+ annotationMarker.style.backgroundColor = "rgba(255, 193, 7, 0.95)";
1141
+ annotationMarker.style.borderRadius = "50%";
1142
+ annotationMarker.style.width = "16px";
1143
+ annotationMarker.style.height = "16px";
1144
+ annotationMarker.style.display = "flex";
1145
+ annotationMarker.style.alignItems = "center";
1146
+ annotationMarker.style.justifyContent = "center";
1147
+ annotationMarker.style.zIndex = "30";
1148
+ annotationMarker.style.boxShadow = "0 1px 3px rgba(0, 0, 0, 0.25)";
1149
+ annotationMarker.style.lineHeight = "1";
1150
+ existingBlock.appendChild(annotationMarker);
1151
+ }
1152
+ }
1153
+ } else {
1154
+ // 移除批注标记
1155
+ if (existingBlock.classList.contains("has-annotation")) {
1156
+ existingBlock.classList.remove("has-annotation");
1157
+ existingBlock.title = "";
1158
+ existingBlock.style.backgroundColor = "transparent";
1159
+ existingBlock.style.border = "none";
1160
+ existingBlock.style.padding = "0";
1161
+ existingBlock.style.boxShadow = "none";
1162
+ const marker = existingBlock.querySelector(".annotation-marker");
1163
+ if (marker) {
1164
+ marker.remove();
1165
+ }
1166
+ }
1167
+ }
1168
+ newBlocksMap.set(bboxKey, true);
1169
+ } else {
1170
+ // 如果不存在,创建新的文本块
1171
+ textLayer.appendChild(blockDiv);
1172
+ newBlocksMap.set(bboxKey, true);
988
1173
  }
1174
+ });
989
1175
 
990
- textLayer.appendChild(blockDiv);
1176
+ // 删除不再存在的文本块(增量删除)
1177
+ existingBlocks.forEach((block, bboxKey) => {
1178
+ if (!newBlocksMap.has(bboxKey)) {
1179
+ block.remove();
1180
+ }
991
1181
  });
992
1182
  } catch (error) {
993
- console.error('❌ 文本图层渲染失败:', error);
1183
+ console.error("❌ 文本图层渲染失败:", error);
994
1184
  }
995
1185
  };
996
1186
 
997
1187
  /**
998
1188
  * 获取文本块对应的批注
999
1189
  */
1000
- const getAnnotationForBlock = (bbox: [number, number, number, number]): AnnotationInfo | null => {
1190
+ const getAnnotationForBlock = (
1191
+ bbox: [number, number, number, number]
1192
+ ): AnnotationInfo | null => {
1001
1193
  if (!props.annotations || props.annotations.length === 0) {
1002
1194
  return null;
1003
1195
  }
1004
-
1196
+
1005
1197
  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;
1198
+ return (
1199
+ props.annotations.find((annotation) => {
1200
+ const [x1, y1, x2, y2] = annotation.blockBbox;
1201
+ return (
1202
+ Math.abs(x1 - bbox[0]) < tolerance &&
1203
+ Math.abs(y1 - bbox[1]) < tolerance &&
1204
+ Math.abs(x2 - bbox[2]) < tolerance &&
1205
+ Math.abs(y2 - bbox[3]) < tolerance &&
1206
+ (annotation.blockPage === undefined ||
1207
+ annotation.blockPage === currentPage.value)
1208
+ );
1209
+ }) || null
1210
+ );
1016
1211
  };
1017
1212
 
1018
1213
  /**
1019
1214
  * 显示文本块的批注按钮
1020
1215
  */
1021
- const showAnnotationButtonForBlock = (event: MouseEvent, blockDiv: HTMLElement) => {
1022
- const text = blockDiv.dataset.text || '';
1023
- const bboxStr = blockDiv.dataset.bbox || '';
1216
+ const showAnnotationButtonForBlock = (
1217
+ event: MouseEvent,
1218
+ blockDiv: HTMLElement
1219
+ ) => {
1220
+ const text = blockDiv.dataset.text || "";
1221
+ const bboxStr = blockDiv.dataset.bbox || "";
1024
1222
  if (!text || !bboxStr) return;
1025
1223
 
1026
1224
  try {
1027
1225
  const bbox = JSON.parse(bboxStr) as [number, number, number, number];
1028
-
1226
+
1029
1227
  const rect = blockDiv.getBoundingClientRect();
1030
1228
  const containerRect = containerRef.value?.getBoundingClientRect();
1031
1229
 
@@ -1038,8 +1236,10 @@ const showAnnotationButtonForBlock = (event: MouseEvent, blockDiv: HTMLElement)
1038
1236
  const spacing = 8;
1039
1237
 
1040
1238
  // 计算相对于容器的坐标(考虑滚动)
1041
- const relativeX = rect.left - containerRect.left + containerRef.value.scrollLeft;
1042
- const relativeY = rect.top - containerRect.top + containerRef.value.scrollTop;
1239
+ const relativeX =
1240
+ rect.left - containerRect.left + containerRef.value.scrollLeft;
1241
+ const relativeY =
1242
+ rect.top - containerRect.top + containerRef.value.scrollTop;
1043
1243
 
1044
1244
  // 容器可用空间
1045
1245
  const containerWidth = containerRef.value.clientWidth;
@@ -1053,7 +1253,9 @@ const showAnnotationButtonForBlock = (event: MouseEvent, blockDiv: HTMLElement)
1053
1253
  const bottomSpace = containerBottom - rectBottom;
1054
1254
  const topSpace = rectTop - containerTop;
1055
1255
 
1056
- const showOnBottom = bottomSpace >= popupHeight + spacing || (bottomSpace > 0 && bottomSpace >= topSpace);
1256
+ const showOnBottom =
1257
+ bottomSpace >= popupHeight + spacing ||
1258
+ (bottomSpace > 0 && bottomSpace >= topSpace);
1057
1259
 
1058
1260
  // 计算最终位置
1059
1261
  let left: number;
@@ -1069,14 +1271,16 @@ const showAnnotationButtonForBlock = (event: MouseEvent, blockDiv: HTMLElement)
1069
1271
  }
1070
1272
 
1071
1273
  // 边界检查
1072
- const popupTopInViewport = top - containerRef.value.scrollTop + containerRect.top;
1274
+ const popupTopInViewport =
1275
+ top - containerRef.value.scrollTop + containerRect.top;
1073
1276
  const popupBottomInViewport = popupTopInViewport + popupHeight;
1074
1277
 
1075
1278
  if (popupBottomInViewport > containerBottom - spacing) {
1076
1279
  top = relativeY - popupHeight - spacing;
1077
1280
  }
1078
1281
 
1079
- const newPopupTopInViewport = top - containerRef.value.scrollTop + containerRect.top;
1282
+ const newPopupTopInViewport =
1283
+ top - containerRef.value.scrollTop + containerRect.top;
1080
1284
  if (newPopupTopInViewport < containerTop + spacing) {
1081
1285
  top = relativeY + rect.height + spacing;
1082
1286
  }
@@ -1091,12 +1295,12 @@ const showAnnotationButtonForBlock = (event: MouseEvent, blockDiv: HTMLElement)
1091
1295
 
1092
1296
  annotationPopupStyle.value = {
1093
1297
  left: `${left}px`,
1094
- top: `${top}px`
1298
+ top: `${top}px`,
1095
1299
  };
1096
1300
 
1097
1301
  showAnnotationPopup.value = true;
1098
1302
  } catch (error) {
1099
- console.error('解析bbox失败:', error);
1303
+ console.error("解析bbox失败:", error);
1100
1304
  }
1101
1305
  };
1102
1306
 
@@ -1108,13 +1312,13 @@ const hideAnnotationButton = () => {
1108
1312
  if (currentAnnotationBlock.value) {
1109
1313
  return;
1110
1314
  }
1111
-
1315
+
1112
1316
  hideTimer = setTimeout(() => {
1113
1317
  showAnnotationPopup.value = false;
1114
1318
 
1115
1319
  if (activeBlockDiv.value && !isHighlighted.value) {
1116
- activeBlockDiv.value.style.backgroundColor = 'transparent';
1117
- activeBlockDiv.value.style.boxShadow = 'none';
1320
+ activeBlockDiv.value.style.backgroundColor = "transparent";
1321
+ activeBlockDiv.value.style.boxShadow = "none";
1118
1322
  activeBlockDiv.value = null;
1119
1323
  }
1120
1324
  }, 300);
@@ -1138,69 +1342,71 @@ const openAnnotationInput = (e?: Event) => {
1138
1342
  if (e) {
1139
1343
  e.stopPropagation();
1140
1344
  }
1141
-
1345
+
1142
1346
  // 获取选中的文本
1143
1347
  const selection = window.getSelection();
1144
- let selectedText = '';
1145
-
1348
+ let selectedText = "";
1349
+
1146
1350
  if (selection && selection.toString().trim().length > 0) {
1147
1351
  selectedText = selection.toString().trim();
1148
1352
  } else if (activeBlockDiv.value) {
1149
- selectedText = activeBlockDiv.value.dataset.text || '';
1353
+ selectedText = activeBlockDiv.value.dataset.text || "";
1150
1354
  }
1151
-
1355
+
1152
1356
  if (!selectedText && !activeBlockDiv.value) return;
1153
-
1357
+
1154
1358
  // 如果有激活的文本块,使用文本块的 bbox
1155
1359
  let bbox: [number, number, number, number] | null = null;
1156
1360
  if (activeBlockDiv.value) {
1157
- const bboxStr = activeBlockDiv.value.dataset.bbox || '';
1361
+ const bboxStr = activeBlockDiv.value.dataset.bbox || "";
1158
1362
  try {
1159
1363
  bbox = JSON.parse(bboxStr) as [number, number, number, number];
1160
1364
  } catch (error) {
1161
- console.error('解析 bbox 失败:', error);
1365
+ console.error("解析 bbox 失败:", error);
1162
1366
  }
1163
1367
  }
1164
-
1368
+
1165
1369
  // 如果没有 bbox,尝试从选中文本的位置计算
1166
1370
  if (!bbox && selection && selection.rangeCount > 0) {
1167
1371
  const range = selection.getRangeAt(0);
1168
1372
  const rect = range.getBoundingClientRect();
1169
1373
  const containerRect = containerRef.value?.getBoundingClientRect();
1170
-
1374
+
1171
1375
  if (containerRect && containerRef.value) {
1172
- const relativeX = rect.left - containerRect.left + containerRef.value.scrollLeft;
1173
- const relativeY = rect.top - containerRect.top + containerRef.value.scrollTop;
1376
+ const relativeX =
1377
+ rect.left - containerRect.left + containerRef.value.scrollLeft;
1378
+ const relativeY =
1379
+ rect.top - containerRect.top + containerRef.value.scrollTop;
1174
1380
  bbox = [
1175
1381
  relativeX,
1176
1382
  relativeY,
1177
1383
  relativeX + rect.width,
1178
- relativeY + rect.height
1384
+ relativeY + rect.height,
1179
1385
  ];
1180
1386
  }
1181
1387
  }
1182
-
1388
+
1183
1389
  if (!bbox) {
1184
- Message.warning('无法确定文本位置');
1390
+ Message.warning("无法确定文本位置");
1185
1391
  return;
1186
1392
  }
1187
-
1393
+
1188
1394
  // 检查是否已有批注
1189
1395
  const existingAnnotation = getAnnotationForBlock(bbox);
1190
1396
  if (existingAnnotation) {
1191
1397
  annotationInput.value = existingAnnotation.content;
1192
1398
  } else {
1193
- annotationInput.value = '';
1399
+ annotationInput.value = "";
1194
1400
  }
1195
-
1401
+
1196
1402
  currentAnnotationBlock.value = {
1197
1403
  bbox,
1198
- content: selectedText
1404
+ content: selectedText,
1199
1405
  };
1200
-
1406
+
1201
1407
  // 确保弹窗显示
1202
1408
  showAnnotationPopup.value = true;
1203
-
1409
+
1204
1410
  // 重新计算弹窗位置(输入框更大)
1205
1411
  nextTick(() => {
1206
1412
  adjustAnnotationPopupPosition();
@@ -1213,56 +1419,62 @@ const openAnnotationInput = (e?: Event) => {
1213
1419
  const adjustAnnotationPopupPosition = () => {
1214
1420
  const containerRect = containerRef.value?.getBoundingClientRect();
1215
1421
  const popup = annotationPopupRef.value;
1216
-
1422
+
1217
1423
  if (!containerRect || !containerRef.value) {
1218
1424
  return;
1219
1425
  }
1220
-
1426
+
1221
1427
  const popupWidth = popup?.getBoundingClientRect().width || 320; // 默认宽度
1222
1428
  const popupHeight = popup?.getBoundingClientRect().height || 200; // 默认高度
1223
1429
  const spacing = 4; // 减少间距,让弹窗更靠近文字
1224
-
1430
+
1225
1431
  // 优先使用文本块位置(和批注按钮位置计算逻辑一致)
1226
1432
  let left: number;
1227
1433
  let top: number;
1228
-
1434
+
1229
1435
  if (activeBlockDiv.value) {
1230
1436
  // 使用文本块位置,和批注按钮使用相同的计算逻辑
1231
1437
  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
-
1438
+ const relativeX =
1439
+ rect.left - containerRect.left + containerRef.value.scrollLeft;
1440
+ const relativeY =
1441
+ rect.top - containerRect.top + containerRef.value.scrollTop;
1442
+
1235
1443
  // 计算可用空间
1236
1444
  const rectBottom = rect.bottom;
1237
1445
  const rectTop = rect.top;
1238
1446
  const containerBottom = containerRect.bottom;
1239
1447
  const containerTop = containerRect.top;
1240
-
1448
+
1241
1449
  const bottomSpace = containerBottom - rectBottom;
1242
1450
  const topSpace = rectTop - containerTop;
1243
-
1451
+
1244
1452
  // 判断弹窗显示在文字下方还是上方
1245
- const showOnBottom = bottomSpace >= popupHeight + spacing || (bottomSpace > 0 && bottomSpace >= topSpace);
1246
-
1453
+ const showOnBottom =
1454
+ bottomSpace >= popupHeight + spacing ||
1455
+ (bottomSpace > 0 && bottomSpace >= topSpace);
1456
+
1247
1457
  // 计算位置(和批注按钮位置计算一致)
1248
1458
  const centerX = relativeX + rect.width / 2;
1249
1459
  left = centerX - popupWidth / 2;
1250
-
1460
+
1251
1461
  if (showOnBottom) {
1252
1462
  top = relativeY + rect.height + spacing;
1253
1463
  } else {
1254
1464
  top = relativeY - popupHeight - spacing;
1255
1465
  }
1256
-
1466
+
1257
1467
  // 边界检查
1258
- const popupTopInViewport = top - containerRef.value.scrollTop + containerRect.top;
1468
+ const popupTopInViewport =
1469
+ top - containerRef.value.scrollTop + containerRect.top;
1259
1470
  const popupBottomInViewport = popupTopInViewport + popupHeight;
1260
-
1471
+
1261
1472
  if (popupBottomInViewport > containerBottom - spacing) {
1262
1473
  top = relativeY - popupHeight - spacing;
1263
1474
  }
1264
-
1265
- const newPopupTopInViewport = top - containerRef.value.scrollTop + containerRect.top;
1475
+
1476
+ const newPopupTopInViewport =
1477
+ top - containerRef.value.scrollTop + containerRect.top;
1266
1478
  if (newPopupTopInViewport < containerTop + spacing) {
1267
1479
  top = relativeY + rect.height + spacing;
1268
1480
  }
@@ -1270,13 +1482,13 @@ const adjustAnnotationPopupPosition = () => {
1270
1482
  // 如果没有文本块,使用当前按钮位置(作为后备方案)
1271
1483
  const currentStyle = annotationPopupStyle.value;
1272
1484
  if (currentStyle && currentStyle.left && currentStyle.top) {
1273
- left = parseFloat(currentStyle.left.toString().replace('px', ''));
1274
- top = parseFloat(currentStyle.top.toString().replace('px', ''));
1485
+ left = parseFloat(currentStyle.left.toString().replace("px", ""));
1486
+ top = parseFloat(currentStyle.top.toString().replace("px", ""));
1275
1487
  } else {
1276
1488
  return;
1277
1489
  }
1278
1490
  }
1279
-
1491
+
1280
1492
  // 确保不超出容器边界
1281
1493
  const containerWidth = containerRef.value.clientWidth;
1282
1494
  if (left + popupWidth > containerWidth - spacing) {
@@ -1285,10 +1497,10 @@ const adjustAnnotationPopupPosition = () => {
1285
1497
  if (left < spacing) {
1286
1498
  left = spacing;
1287
1499
  }
1288
-
1500
+
1289
1501
  annotationPopupStyle.value = {
1290
1502
  left: `${left}px`,
1291
- top: `${top}px`
1503
+ top: `${top}px`,
1292
1504
  };
1293
1505
  };
1294
1506
 
@@ -1297,7 +1509,7 @@ const adjustAnnotationPopupPosition = () => {
1297
1509
  */
1298
1510
  const closeAnnotationInput = () => {
1299
1511
  currentAnnotationBlock.value = null;
1300
- annotationInput.value = '';
1512
+ annotationInput.value = "";
1301
1513
  showAnnotationPopup.value = false;
1302
1514
  };
1303
1515
 
@@ -1310,7 +1522,7 @@ const handleAnnotationPopupLeave = (e: MouseEvent) => {
1310
1522
  if (relatedTarget && annotationPopupRef.value?.contains(relatedTarget)) {
1311
1523
  return;
1312
1524
  }
1313
-
1525
+
1314
1526
  // 延迟关闭,给用户时间操作
1315
1527
  hideTimer = setTimeout(() => {
1316
1528
  closeAnnotationInput();
@@ -1324,35 +1536,37 @@ const saveAnnotation = () => {
1324
1536
  if (!currentAnnotationBlock.value || !annotationInput.value.trim()) {
1325
1537
  return;
1326
1538
  }
1327
-
1539
+
1328
1540
  const { bbox, content } = currentAnnotationBlock.value;
1329
1541
  const annotationContent = annotationInput.value.trim();
1330
-
1542
+
1331
1543
  // 检查是否已有批注
1332
1544
  const existingAnnotation = getAnnotationForBlock(bbox);
1333
-
1545
+
1334
1546
  const annotation: AnnotationInfo = {
1335
- id: existingAnnotation?.id || `annotation_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
1547
+ id:
1548
+ existingAnnotation?.id ||
1549
+ `annotation_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
1336
1550
  blockBbox: bbox,
1337
1551
  blockContent: content,
1338
1552
  blockPage: currentPage.value,
1339
1553
  content: annotationContent,
1340
- createTime: existingAnnotation?.createTime || Date.now()
1554
+ createTime: existingAnnotation?.createTime || Date.now(),
1341
1555
  };
1342
-
1556
+
1343
1557
  if (existingAnnotation) {
1344
1558
  // 更新已有批注
1345
- emit('annotation-update', annotation);
1346
- Message.success('批注已更新');
1559
+ emit("annotation-update", annotation);
1560
+ Message.success("批注已更新");
1347
1561
  } else {
1348
1562
  // 添加新批注
1349
- emit('annotation-add', annotation);
1350
- Message.success('批注已添加');
1563
+ emit("annotation-add", annotation);
1564
+ Message.success("批注已添加");
1351
1565
  }
1352
-
1566
+
1353
1567
  // 关闭输入框
1354
1568
  closeAnnotationInput();
1355
-
1569
+
1356
1570
  // 重新渲染文本图层以显示批注标记
1357
1571
  nextTick(() => {
1358
1572
  renderTextLayer();
@@ -1372,85 +1586,134 @@ const handleScroll = (e: Event) => {
1372
1586
  showAnnotationPopup.value = false;
1373
1587
  activeBlockDiv.value = null;
1374
1588
  }
1375
-
1589
+
1376
1590
  // 在自适应宽度模式下,滚动操作不应该改变缩放比例
1377
1591
  // 如果缩放比例被意外改变,恢复它
1378
- if (props.autoFitWidth &&
1379
- !isUserZooming.value &&
1380
- initialAutoFitScale.value !== null &&
1381
- Math.abs(scale.value - initialAutoFitScale.value) > 0.01) {
1592
+ if (
1593
+ props.autoFitWidth &&
1594
+ !isUserZooming.value &&
1595
+ initialAutoFitScale.value !== null &&
1596
+ Math.abs(scale.value - initialAutoFitScale.value) > 0.01
1597
+ ) {
1382
1598
  scale.value = initialAutoFitScale.value;
1383
1599
  }
1384
-
1600
+
1385
1601
  // 滚动翻页功能
1386
- if (props.enableScrollPaging && totalPages.value > 1 && !isScrollPaging.value) {
1602
+ if (
1603
+ props.enableScrollPaging &&
1604
+ totalPages.value > 1 &&
1605
+ !isScrollPaging.value
1606
+ ) {
1387
1607
  handleScrollPaging(e);
1388
1608
  }
1389
1609
  };
1390
1610
 
1611
+ // 记录上次滚动位置,用于判断滚动方向
1612
+ let lastScrollTop = 0;
1613
+
1391
1614
  /**
1392
- * 处理滚动翻页
1615
+ * 处理滚动翻页(通过滚动位置判断当前页)
1616
+ * 向下滑动:当视口顶部到达下一页的顶部时,切换到下一页
1617
+ * 向上滑动:当视口底部到达上一页的底部时,切换到上一页
1393
1618
  */
1394
1619
  const handleScrollPaging = (e: Event) => {
1395
1620
  const container = e.target as HTMLElement;
1396
- if (!container) return;
1397
-
1621
+ if (!container || !containerRef.value) return;
1622
+
1398
1623
  // 如果正在翻页中,直接返回
1399
1624
  if (isScrollPaging.value) {
1400
1625
  return;
1401
1626
  }
1402
-
1627
+
1403
1628
  // 清除之前的定时器
1404
1629
  if (scrollPagingTimer) {
1405
1630
  clearTimeout(scrollPagingTimer);
1406
1631
  }
1407
-
1408
- // 使用防抖,避免频繁触发(增加延迟时间减少卡顿)
1632
+
1633
+ // 使用防抖,避免频繁触发
1409
1634
  scrollPagingTimer = setTimeout(() => {
1410
1635
  // 再次检查是否正在翻页
1411
1636
  if (isScrollPaging.value) {
1412
1637
  return;
1413
1638
  }
1414
-
1639
+
1415
1640
  const scrollTop = container.scrollTop;
1416
- const scrollHeight = container.scrollHeight;
1417
1641
  const clientHeight = container.clientHeight;
1642
+ const scrollBottom = scrollTop + clientHeight;
1418
1643
 
1419
- // 滚动到底部(留 100px 的缓冲区,避免误触发)
1420
- if (scrollTop + clientHeight >= scrollHeight - 100) {
1644
+ // 判断滚动方向
1645
+ const isScrollingDown = scrollTop > lastScrollTop;
1646
+ lastScrollTop = scrollTop;
1647
+
1648
+ // 获取当前页的元素
1649
+ const currentPageElement = container.querySelector(
1650
+ `[data-page-number="${currentPage.value}"]`
1651
+ ) as HTMLElement;
1652
+
1653
+ if (!currentPageElement) return;
1654
+
1655
+ const currentPageTop = currentPageElement.offsetTop;
1656
+ const currentPageHeight = currentPageElement.offsetHeight;
1657
+ const currentPageBottom = currentPageTop + currentPageHeight;
1658
+
1659
+ let newPage = currentPage.value;
1660
+
1661
+ if (isScrollingDown) {
1662
+ // 向下滑动:当视口顶部到达或超过下一页的顶部时,切换到下一页
1421
1663
  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);
1664
+ const nextPageElement = container.querySelector(
1665
+ `[data-page-number="${currentPage.value + 1}"]`
1666
+ ) as HTMLElement;
1667
+
1668
+ if (nextPageElement) {
1669
+ const nextPageTop = nextPageElement.offsetTop;
1670
+ // 当视口顶部到达或超过下一页的顶部时,切换到下一页
1671
+ if (scrollTop >= nextPageTop) {
1672
+ newPage = currentPage.value + 1;
1673
+ }
1674
+ }
1431
1675
  }
1432
- }
1433
- // 滚动到顶部(留 100px 的缓冲区)
1434
- else if (scrollTop <= 100) {
1676
+ } else {
1677
+ // 向上滑动:当视口底部到达或超过上一页的底部时,切换到上一页
1435
1678
  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);
1679
+ const prevPageElement = container.querySelector(
1680
+ `[data-page-number="${currentPage.value - 1}"]`
1681
+ ) as HTMLElement;
1682
+
1683
+ if (prevPageElement) {
1684
+ const prevPageTop = prevPageElement.offsetTop;
1685
+ const prevPageHeight = prevPageElement.offsetHeight;
1686
+ const prevPageBottom = prevPageTop + prevPageHeight;
1687
+
1688
+ // 当视口底部到达或超过上一页的底部时,切换到上一页
1689
+ if (scrollBottom <= prevPageBottom) {
1690
+ newPage = currentPage.value - 1;
1691
+ }
1692
+ }
1445
1693
  }
1446
1694
  }
1447
- }, 200); // 增加防抖时间到 200ms,减少卡顿
1695
+
1696
+ // 如果页码发生变化,更新当前页
1697
+ if (newPage !== currentPage.value) {
1698
+ isScrollPaging.value = true;
1699
+ currentPage.value = newPage;
1700
+ emit("page-change", newPage, totalPages.value);
1701
+
1702
+ // 延迟解锁,确保页面切换完成
1703
+ setTimeout(() => {
1704
+ isScrollPaging.value = false;
1705
+ }, 100);
1706
+ }
1707
+ }, 100);
1448
1708
  };
1449
1709
 
1450
1710
  /**
1451
1711
  * 检查元素是否在视口内可见
1452
1712
  */
1453
- const isElementVisible = (element: HTMLElement, container: HTMLElement): boolean => {
1713
+ const isElementVisible = (
1714
+ element: HTMLElement,
1715
+ container: HTMLElement
1716
+ ): boolean => {
1454
1717
  const elementRect = element.getBoundingClientRect();
1455
1718
  const containerRect = container.getBoundingClientRect();
1456
1719
 
@@ -1464,38 +1727,51 @@ const isElementVisible = (element: HTMLElement, container: HTMLElement): boolean
1464
1727
 
1465
1728
  /**
1466
1729
  * 高亮指定位置
1467
- * @param bbox 块的边界框 [x1, y1, x2, y2]
1730
+ * @param pageNum 页码
1731
+ * @param bbox 块的边界框 [x1, y1, x2, y2](相对于该页面的坐标)
1468
1732
  * @param shouldScroll 是否应该滚动到元素位置
1469
1733
  * @returns 是否成功找到并高亮了元素
1470
1734
  */
1471
1735
  const highlightPosition = (
1736
+ pageNum: number,
1472
1737
  bbox: [number, number, number, number],
1473
1738
  shouldScroll: boolean = true
1474
1739
  ): boolean => {
1475
1740
  // 清除之前的高亮
1476
1741
  if (activeBlockDiv.value) {
1477
- activeBlockDiv.value.style.backgroundColor = 'transparent';
1478
- activeBlockDiv.value.style.boxShadow = 'none';
1742
+ activeBlockDiv.value.style.backgroundColor = "transparent";
1743
+ activeBlockDiv.value.style.boxShadow = "none";
1479
1744
  activeBlockDiv.value = null;
1480
1745
  }
1481
1746
  isHighlighted.value = false;
1482
1747
 
1483
- const textLayer = textLayerRef.value;
1748
+ // 如果页码不在有效范围内,返回 false
1749
+ if (pageNum < 1 || pageNum > totalPages.value) {
1750
+ return false;
1751
+ }
1752
+
1753
+ // 在指定页面的文本图层中查找
1754
+ const textLayer = textLayerRefs.get(pageNum);
1484
1755
  if (!textLayer) return false;
1485
1756
 
1486
- // 查找对应的块元素,使用存储的bbox数据匹配(使用容差)
1487
- const blockDivs = textLayer.querySelectorAll('.text-block');
1488
1757
  const tolerance = 2;
1489
-
1490
1758
  let matchedElement: HTMLElement | null = null;
1491
1759
 
1760
+ // 查找对应的块元素,使用存储的bbox数据匹配(使用容差)
1761
+ const blockDivs = textLayer.querySelectorAll(".text-block");
1762
+
1492
1763
  blockDivs.forEach((div) => {
1493
1764
  const el = div as HTMLElement;
1494
1765
  const storedBbox = el.dataset.bbox;
1495
1766
  if (!storedBbox) return;
1496
1767
 
1497
1768
  try {
1498
- const parsedBbox = JSON.parse(storedBbox) as [number, number, number, number];
1769
+ const parsedBbox = JSON.parse(storedBbox) as [
1770
+ number,
1771
+ number,
1772
+ number,
1773
+ number
1774
+ ];
1499
1775
 
1500
1776
  const isMatch =
1501
1777
  Math.abs(parsedBbox[0] - bbox[0]) < tolerance &&
@@ -1507,35 +1783,48 @@ const highlightPosition = (
1507
1783
  matchedElement = el;
1508
1784
  }
1509
1785
  } catch (error) {
1510
- console.warn('解析bbox失败:', error);
1786
+ console.warn("解析bbox失败:", error);
1511
1787
  }
1512
1788
  });
1513
1789
 
1514
1790
  if (!matchedElement) return false;
1515
1791
 
1792
+ // 如果找到的元素不在当前页,切换到对应页面
1793
+ if (pageNum !== currentPage.value) {
1794
+ switchToPage(pageNum);
1795
+ // 等待页面切换完成后再高亮
1796
+ nextTick(() => {
1797
+ setTimeout(() => {
1798
+ highlightPosition(pageNum, bbox, shouldScroll);
1799
+ }, 300);
1800
+ });
1801
+ return true;
1802
+ }
1803
+
1516
1804
  // 保存引用
1517
- const elementRef = matchedElement;
1805
+ const elementRef = matchedElement as HTMLElement;
1518
1806
 
1519
1807
  activeBlockDiv.value = elementRef;
1520
1808
  isHighlighted.value = true;
1521
1809
 
1522
1810
  // 使用一致的高亮样式
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)';
1811
+ elementRef.style.backgroundColor =
1812
+ "var(--s-color-brand-primary-transparent-3, rgba(0, 102, 255, .15))";
1813
+ elementRef.style.boxShadow = "0 0 0 2px rgba(30, 144, 255, 0.6)";
1525
1814
 
1526
1815
  // 只有在需要滚动且元素不在视口内时才滚动
1527
1816
  if (shouldScroll && containerRef.value) {
1528
1817
  const isVisible = isElementVisible(elementRef, containerRef.value);
1529
1818
  if (!isVisible) {
1530
- elementRef.scrollIntoView({ behavior: 'smooth', block: 'center' });
1819
+ elementRef.scrollIntoView({ behavior: "smooth", block: "center" });
1531
1820
  }
1532
1821
  }
1533
1822
 
1534
1823
  // 5秒后自动取消高亮
1535
1824
  setTimeout(() => {
1536
1825
  if (activeBlockDiv.value === elementRef && isHighlighted.value) {
1537
- elementRef.style.backgroundColor = 'transparent';
1538
- elementRef.style.boxShadow = 'none';
1826
+ elementRef.style.backgroundColor = "transparent";
1827
+ elementRef.style.boxShadow = "none";
1539
1828
  activeBlockDiv.value = null;
1540
1829
  isHighlighted.value = false;
1541
1830
  }
@@ -1546,13 +1835,21 @@ const highlightPosition = (
1546
1835
 
1547
1836
  /**
1548
1837
  * 跳转到指定位置并高亮(外部调用接口)
1549
- * @param bbox 位置的边界框 [x1, y1, x2, y2]
1838
+ * @param pageNum 页码(必须)
1839
+ * @param bbox 位置的边界框 [x1, y1, x2, y2](相对于该页面的坐标)
1550
1840
  * @param emitEvent 是否触发跳转事件,默认为 true
1551
1841
  */
1552
1842
  const jumpToPosition = (
1843
+ pageNum: number,
1553
1844
  bbox: [number, number, number, number],
1554
1845
  emitEvent: boolean = true
1555
1846
  ) => {
1847
+ // 如果页码不在有效范围内,直接返回
1848
+ if (pageNum < 1 || pageNum > totalPages.value) {
1849
+ console.warn(`页码 ${pageNum} 不在有效范围内 (1-${totalPages.value})`);
1850
+ return;
1851
+ }
1852
+
1556
1853
  // 等待DOM更新后再高亮
1557
1854
  nextTick(() => {
1558
1855
  let retryCount = 0;
@@ -1560,17 +1857,17 @@ const jumpToPosition = (
1560
1857
  const retryDelay = 200;
1561
1858
 
1562
1859
  const tryHighlight = () => {
1563
- const success = highlightPosition(bbox, true);
1860
+ const success = highlightPosition(pageNum, bbox, true);
1564
1861
  if (success) {
1565
1862
  // 高亮成功,触发事件
1566
1863
  if (emitEvent) {
1567
- emit('position-jump', bbox, 'block');
1864
+ emit("position-jump", bbox, "block");
1568
1865
  }
1569
1866
  } else if (retryCount < maxRetries) {
1570
1867
  retryCount++;
1571
1868
  setTimeout(tryHighlight, retryDelay);
1572
1869
  } else {
1573
- console.warn(`无法找到并高亮指定位置: bbox: [${bbox.join(', ')}]`);
1870
+ console.warn(`无法找到并高亮指定位置: 页码 ${pageNum}, bbox: [${bbox.join(", ")}]`);
1574
1871
  }
1575
1872
  };
1576
1873
 
@@ -1579,65 +1876,58 @@ const jumpToPosition = (
1579
1876
  };
1580
1877
 
1581
1878
  /**
1582
- * 监听 blocksData 变化,重新渲染文本图层
1879
+ * 监听 blocksData 变化,增量渲染所有页面的文本图层(避免闪烁)
1583
1880
  */
1584
1881
  watch(
1585
1882
  () => props.blocksData,
1586
1883
  () => {
1587
1884
  nextTick(() => {
1588
- renderTextLayer();
1885
+ // 渲染所有页面的文本图层
1886
+ for (let pageNum = 1; pageNum <= totalPages.value; pageNum++) {
1887
+ renderTextLayer(pageNum);
1888
+ }
1589
1889
  });
1590
1890
  },
1591
- { deep: true, immediate: false }
1891
+ { deep: true, immediate: true }
1592
1892
  );
1593
1893
 
1594
1894
  /**
1595
- * 监听 annotations 变化,重新渲染文本图层以显示批注标记
1895
+ * 监听 annotations 变化,增量更新所有页面的文本图层以显示批注标记
1596
1896
  */
1597
1897
  watch(
1598
1898
  () => props.annotations,
1599
1899
  () => {
1600
1900
  nextTick(() => {
1601
- renderTextLayer();
1901
+ // 更新所有页面的文本图层
1902
+ for (let pageNum = 1; pageNum <= totalPages.value; pageNum++) {
1903
+ renderTextLayer(pageNum);
1904
+ }
1602
1905
  });
1603
1906
  },
1604
1907
  { deep: true }
1605
1908
  );
1606
1909
 
1607
1910
  /**
1608
- * 监听当前页码变化,重新渲染文本图层
1911
+ * 监听当前页码变化,确保当前页的文本图层已渲染
1609
1912
  */
1610
1913
  watch(
1611
1914
  () => currentPage.value,
1612
1915
  () => {
1613
1916
  nextTick(() => {
1614
- renderTextLayer();
1917
+ renderTextLayer(currentPage.value);
1615
1918
  });
1616
1919
  }
1617
1920
  );
1618
1921
 
1619
1922
  /**
1620
- * 监听当前页码的blocksData变化,重新渲染文本图层
1621
- */
1622
- watch(
1623
- () => currentPageBlocksData.value,
1624
- () => {
1625
- nextTick(() => {
1626
- renderTextLayer();
1627
- });
1628
- },
1629
- { deep: true }
1630
- );
1631
-
1632
- /**
1633
- * 监听图片尺寸变化,重新渲染文本图层
1923
+ * 监听图片尺寸变化,重新渲染当前页的文本图层
1634
1924
  */
1635
1925
  watch(
1636
1926
  () => imageSize.value,
1637
1927
  () => {
1638
1928
  if (imageSize.value.width > 0 && imageSize.value.height > 0) {
1639
1929
  nextTick(() => {
1640
- renderTextLayer();
1930
+ renderTextLayer(currentPage.value);
1641
1931
  });
1642
1932
  }
1643
1933
  },
@@ -1647,27 +1937,36 @@ watch(
1647
1937
  /**
1648
1938
  * 监听缩放、旋转、位置变化,更新文本图层位置
1649
1939
  */
1650
- watch([scale, rotation, position], () => {
1651
- // 文本图层会跟随图片的 transform,不需要手动更新位置
1652
- // 但需要确保文本图层与图片同步变换
1653
- }, { deep: true });
1940
+ watch(
1941
+ [scale, rotation, position],
1942
+ () => {
1943
+ // 文本图层会跟随图片的 transform,不需要手动更新位置
1944
+ // 但需要确保文本图层与图片同步变换
1945
+ },
1946
+ { deep: true }
1947
+ );
1654
1948
 
1655
1949
  /**
1656
1950
  * 组件挂载时的初始化
1657
1951
  */
1658
1952
  onMounted(() => {
1659
- // 如果图片已经加载完成,立即渲染文本图层和自适应宽度
1660
- if (imageRef.value && imageRef.value.complete) {
1661
- nextTick(() => {
1662
- // 如果启用自适应宽度,计算并设置初始缩放比例
1663
- if (props.autoFitWidth && imageSize.value.width > 0) {
1664
- const autoScale = calculateAutoFitScale();
1665
- if (autoScale !== 1) {
1666
- scale.value = autoScale;
1667
- }
1668
- }
1669
- renderTextLayer();
1670
- });
1953
+ // 如果第一页图片已经加载完成,立即计算自适应宽度
1954
+ const firstPageImage = imageRefs.get(1);
1955
+ if (firstPageImage && firstPageImage.complete && props.autoFitWidth) {
1956
+ const firstPageSize = imageSizes.get(1);
1957
+ if (firstPageSize && firstPageSize.width > 0) {
1958
+ nextTick(() => {
1959
+ nextTick(() => {
1960
+ setTimeout(() => {
1961
+ const autoScale = calculateAutoFitScale();
1962
+ if (autoScale !== 1 && autoScale > 0) {
1963
+ scale.value = autoScale;
1964
+ initialAutoFitScale.value = autoScale;
1965
+ }
1966
+ }, 100);
1967
+ });
1968
+ });
1969
+ }
1671
1970
  }
1672
1971
  });
1673
1972
 
@@ -1690,12 +1989,15 @@ defineExpose({
1690
1989
  jumpToPosition, // 暴露定位方法给父组件
1691
1990
  goToPage: switchToPage, // 暴露翻页方法给父组件
1692
1991
  getCurrentPage: () => currentPage.value, // 获取当前页码
1693
- getTotalPages: () => totalPages.value // 获取总页数
1992
+ getTotalPages: () => totalPages.value, // 获取总页数
1694
1993
  });
1695
1994
  </script>
1696
1995
 
1697
1996
  <style lang="less" scoped>
1698
1997
  // 样式已统一到公共样式文件,这里只保留组件特定样式
1998
+ .toolbar-group {
1999
+ gap: 0 !important;
2000
+ }
1699
2001
 
1700
2002
  // 图片容器
1701
2003
  .image-container {
@@ -1719,8 +2021,8 @@ defineExpose({
1719
2021
  // 图片包装器容器(用于居中显示和提供滚动空间)
1720
2022
  .image-wrapper-container {
1721
2023
  display: flex;
1722
- align-items: flex-start; // 改为顶部对齐,避免垂直居中产生间隙
1723
- justify-content: center;
2024
+ flex-direction: column; // 垂直排列所有页面
2025
+ align-items: center; // 水平居中
1724
2026
  width: 100%;
1725
2027
  min-height: 100%;
1726
2028
  box-sizing: border-box;
@@ -1734,6 +2036,17 @@ defineExpose({
1734
2036
  padding: 0;
1735
2037
  }
1736
2038
 
2039
+ // 每个页面的容器
2040
+ .image-page-container {
2041
+ position: relative;
2042
+ display: flex;
2043
+ justify-content: center;
2044
+ width: 100%;
2045
+ flex-shrink: 0;
2046
+ margin: 0;
2047
+ padding: 0;
2048
+ }
2049
+
1737
2050
  // 图片包装器(包含图片和文本图层)
1738
2051
  .image-wrapper {
1739
2052
  position: relative;
@@ -1747,7 +2060,7 @@ defineExpose({
1747
2060
  margin: 0;
1748
2061
  padding: 0;
1749
2062
  line-height: 0;
1750
-
2063
+
1751
2064
  img {
1752
2065
  display: block; // 移除图片底部默认间隙
1753
2066
  margin: 0;
@@ -1808,7 +2121,7 @@ defineExpose({
1808
2121
  border-radius: 6px;
1809
2122
  box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
1810
2123
  animation: fade-in 0.2s ease;
1811
-
2124
+
1812
2125
  .annotation-button-action {
1813
2126
  display: flex;
1814
2127
  flex-direction: row;
@@ -1838,20 +2151,20 @@ defineExpose({
1838
2151
  box-shadow: 0 4px 16px rgb(0 0 0 / 20%);
1839
2152
  animation: fade-in 0.2s ease;
1840
2153
  overflow: hidden;
1841
-
2154
+
1842
2155
  .annotation-input-header {
1843
2156
  display: flex;
1844
2157
  align-items: center;
1845
2158
  justify-content: space-between;
1846
2159
  padding: 12px 16px;
1847
2160
  border-bottom: 1px solid #e5e7eb;
1848
-
2161
+
1849
2162
  .annotation-input-title {
1850
2163
  font-size: 14px;
1851
2164
  font-weight: 500;
1852
2165
  color: #1d2129;
1853
2166
  }
1854
-
2167
+
1855
2168
  .annotation-close-btn {
1856
2169
  display: flex;
1857
2170
  align-items: center;
@@ -1865,22 +2178,22 @@ defineExpose({
1865
2178
  border-radius: 4px;
1866
2179
  color: #86909c;
1867
2180
  transition: all 0.2s;
1868
-
2181
+
1869
2182
  &:hover {
1870
2183
  background-color: #f0f0f0;
1871
2184
  color: #1d2129;
1872
2185
  }
1873
2186
  }
1874
2187
  }
1875
-
2188
+
1876
2189
  .annotation-input-content {
1877
2190
  padding: 12px 16px;
1878
-
2191
+
1879
2192
  .annotation-textarea {
1880
2193
  width: 100%;
1881
2194
  }
1882
2195
  }
1883
-
2196
+
1884
2197
  .annotation-input-footer {
1885
2198
  display: flex;
1886
2199
  align-items: center;
@@ -1903,17 +2216,17 @@ defineExpose({
1903
2216
  padding: 1px 3px;
1904
2217
  // 添加阴影效果
1905
2218
  box-shadow: 0 1px 2px rgba(255, 193, 7, 0.25) !important;
1906
-
2219
+
1907
2220
  // 鼠标悬停时加深效果
1908
2221
  &:hover {
1909
2222
  background-color: rgba(255, 243, 205, 0.7) !important;
1910
2223
  border-color: rgba(255, 193, 7, 0.9) !important;
1911
2224
  box-shadow: 0 2px 4px rgba(255, 193, 7, 0.35) !important;
1912
2225
  }
1913
-
2226
+
1914
2227
  // 使用伪元素添加批注标记图标
1915
2228
  &::before {
1916
- content: '📝';
2229
+ content: "📝";
1917
2230
  position: absolute;
1918
2231
  top: -6px;
1919
2232
  right: -6px;