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

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.
@@ -71,8 +71,13 @@
71
71
  <Download :size="16" />
72
72
  </AButton>
73
73
  </ATooltip>
74
- <div class="toolbar-divider"></div>
75
- <ATooltip mini position="bottom" content="向左旋转">
74
+ <div v-if="showRotateButtons" class="toolbar-divider"></div>
75
+ <ATooltip
76
+ v-if="showRotateButtons"
77
+ mini
78
+ position="bottom"
79
+ content="向左旋转"
80
+ >
76
81
  <AButton
77
82
  size="small"
78
83
  type="outline"
@@ -81,7 +86,12 @@
81
86
  <RotateCw :size="16" />
82
87
  </AButton>
83
88
  </ATooltip>
84
- <ATooltip mini position="bottom" content="向右旋转">
89
+ <ATooltip
90
+ v-if="showRotateButtons"
91
+ mini
92
+ position="bottom"
93
+ content="向右旋转"
94
+ >
85
95
  <AButton
86
96
  size="small"
87
97
  type="outline"
@@ -90,6 +100,33 @@
90
100
  <RotateCcw :size="16" />
91
101
  </AButton>
92
102
  </ATooltip>
103
+ <!-- 翻页控件(仅在有多张图片时显示,放在最右侧) -->
104
+ <template v-if="totalPages > 1">
105
+ <div class="toolbar-divider"></div>
106
+ <ATooltip mini position="bottom" content="上一页">
107
+ <AButton
108
+ size="small"
109
+ type="outline"
110
+ :disabled="currentPage <= 1"
111
+ @click="goToPreviousPage"
112
+ >
113
+ <ChevronLeft :size="16" />
114
+ </AButton>
115
+ </ATooltip>
116
+ <span class="page-info">
117
+ {{ currentPage }} / {{ totalPages }}
118
+ </span>
119
+ <ATooltip mini position="bottom" content="下一页">
120
+ <AButton
121
+ size="small"
122
+ type="outline"
123
+ :disabled="currentPage >= totalPages"
124
+ @click="goToNextPage"
125
+ >
126
+ <ChevronRight :size="16" />
127
+ </AButton>
128
+ </ATooltip>
129
+ </template>
93
130
  </div>
94
131
  </div>
95
132
 
@@ -98,7 +135,7 @@
98
135
  class="image-container"
99
136
  @mousedown="startPan"
100
137
  @mousemove="pan"
101
- @mouseup="stopPan"
138
+ @mouseup="handleMouseUp"
102
139
  @mouseleave="stopPan"
103
140
  @scroll="handleScroll"
104
141
  >
@@ -110,44 +147,83 @@
110
147
  class="image-wrapper"
111
148
  :style="{
112
149
  transform: `translate(${position.x}px, ${position.y}px) rotate(${rotation}deg) scale(${scale})`,
113
- transformOrigin: 'center center'
150
+ transformOrigin: 'top center'
114
151
  }"
115
152
  >
116
153
  <img
117
- v-if="url"
154
+ v-if="currentImageUrl"
118
155
  ref="imageRef"
119
- :src="url"
156
+ :src="currentImageUrl"
120
157
  alt="预览图片"
121
158
  :style="{
122
159
  cursor: isPanning ? 'grabbing' : 'grab',
123
- display: 'block'
160
+ display: 'block',
161
+ pointerEvents: currentPageBlocksData.value && currentPageBlocksData.value.length > 0 ? 'none' : 'auto'
124
162
  }"
125
163
  @contextmenu.prevent
126
- @mousedown.prevent
164
+ @mousedown="handleImageMouseDown"
127
165
  @load="onImageLoad"
128
166
  />
129
167
 
130
168
  <!-- 文本图层(用于文本块选择和定位) -->
131
169
  <div
132
- v-if="blocksData && blocksData.length > 0"
170
+ v-if="currentPageBlocksData && currentPageBlocksData.length > 0"
133
171
  ref="textLayerRef"
134
172
  class="text-layer"
135
173
  ></div>
136
174
  </div>
137
175
  </div>
138
176
 
139
- <!-- 复制按钮浮层 -->
177
+ <!-- 批注按钮浮层(通过右键菜单触发) -->
178
+ <div
179
+ v-if="showAnnotationPopup && !currentAnnotationBlock"
180
+ ref="annotationButtonRef"
181
+ class="annotation-button-popup"
182
+ :style="annotationPopupStyle"
183
+ @mouseenter="cancelHideAnnotationButton"
184
+ @mouseleave="hideAnnotationButton"
185
+ >
186
+ <div class="annotation-button-action" @click.stop="openAnnotationInput">
187
+ <MessageSquare :size="12" style="margin-right: 4px" />
188
+ 批注
189
+ </div>
190
+ </div>
191
+
192
+ <!-- 批注输入弹窗 -->
140
193
  <div
141
- v-if="showCopyButton"
142
- ref="copyButtonRef"
143
- class="copy-button-popup"
144
- :style="copyButtonStyle"
145
- @mouseenter="cancelHideCopyButton"
146
- @mouseleave="hideCopyButtonAndHighlight"
194
+ v-if="showAnnotationPopup && currentAnnotationBlock"
195
+ ref="annotationPopupRef"
196
+ class="annotation-input-popup"
197
+ :style="annotationPopupStyle"
198
+ @mouseenter="cancelHideAnnotationButton"
147
199
  >
148
- <div class="copy-button-popup-action" @click="copySelectedText">
149
- <Copy :size="12" style="margin-right: 4px" />
150
- 复制
200
+ <div class="annotation-input-header">
201
+ <span class="annotation-input-title">添加批注</span>
202
+ <button class="annotation-close-btn" @click="closeAnnotationInput">
203
+ <X :size="14" />
204
+ </button>
205
+ </div>
206
+ <div class="annotation-input-content">
207
+ <Textarea
208
+ v-model="annotationInput"
209
+ :auto-size="{ minRows: 3, maxRows: 6 }"
210
+ placeholder="请输入批注内容..."
211
+ class="annotation-textarea"
212
+ @keydown.ctrl.enter="saveAnnotation"
213
+ @keydown.meta.enter="saveAnnotation"
214
+ @focus="cancelHideAnnotationButton"
215
+ @blur="cancelHideAnnotationButton"
216
+ @click.stop
217
+ @mousedown.stop
218
+ />
219
+ </div>
220
+ <div class="annotation-input-footer">
221
+ <AButton size="small" type="outline" @click="closeAnnotationInput">
222
+ 取消
223
+ </AButton>
224
+ <AButton size="small" type="primary" @click="saveAnnotation" :disabled="!annotationInput.trim()">
225
+ 保存
226
+ </AButton>
151
227
  </div>
152
228
  </div>
153
229
  </div>
@@ -156,22 +232,34 @@
156
232
 
157
233
  <script setup lang="ts">
158
234
  import { ref, watch, nextTick, onMounted, onBeforeUnmount, computed } from 'vue';
159
- import { ZoomIn, ZoomOut, RefreshCcw, RotateCw, RotateCcw, Download, Maximize2, Copy } from 'lucide-vue-next';
160
- import { Button as AButton, Tooltip as ATooltip, Message } from '@arco-design/web-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';
161
237
 
162
238
  /**
163
- * 文本块数据结构(与 PdfPreview 保持一致)
239
+ * 文本块数据结构
164
240
  */
165
241
  interface BlockInfo {
166
- blockLabel: string; // 块标签
167
- blockContent: string; // 块内容
168
- blockBbox: [number, number, number, number]; // 块的边界框 [x1, y1, x2, y2]
169
- blockPage?: number; // 块所属的页码(图片预览中通常为1,可选)
242
+ pageNo: number; // 页码
243
+ content: string; // 内容
244
+ bbox: [number, number, number, number]; // 边界框 [x1, y1, x2, y2]
245
+ }
246
+
247
+ /**
248
+ * 批注数据结构
249
+ */
250
+ interface AnnotationInfo {
251
+ id: string; // 批注唯一ID
252
+ blockBbox: [number, number, number, number]; // 关联的文本块边界框
253
+ blockContent: string; // 关联的文本内容
254
+ blockPage?: number; // 所属页码
255
+ content: string; // 批注内容
256
+ createTime: number; // 创建时间戳
170
257
  }
171
258
 
172
259
  const props = defineProps({
260
+ // 支持单个URL(向后兼容)或URL数组
173
261
  url: {
174
- type: String,
262
+ type: [String, Array] as () => string | string[],
175
263
  required: true
176
264
  },
177
265
  minScale: {
@@ -205,27 +293,73 @@ const props = defineProps({
205
293
  enableWheelZoom: {
206
294
  type: Boolean,
207
295
  default: true // 默认启用滚轮缩放,保持向后兼容
296
+ },
297
+ // 批注数据(从外部传入,用于显示已有批注)
298
+ annotations: {
299
+ type: Array as () => AnnotationInfo[],
300
+ default: () => []
301
+ },
302
+ // 是否在初始加载时自适应宽度
303
+ autoFitWidth: {
304
+ type: Boolean,
305
+ default: true
306
+ },
307
+ // 是否启用滚动翻页
308
+ enableScrollPaging: {
309
+ type: Boolean,
310
+ default: true
311
+ },
312
+ // 是否显示旋转按钮
313
+ showRotateButtons: {
314
+ type: Boolean,
315
+ default: true
208
316
  }
209
317
  });
210
318
 
211
319
  const emit = defineEmits<{
212
320
  'original': [];
213
321
  'download': [];
214
- 'load': [data: { width: number; height: number }];
322
+ 'load': [data: { width: number; height: number; page?: number; totalPages?: number }];
215
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]; // 删除批注事件
216
328
  }>();
217
329
 
330
+ // 图片URL数组和当前页码
331
+ const imageUrls = computed(() => {
332
+ if (Array.isArray(props.url)) {
333
+ return props.url;
334
+ }
335
+ return [props.url];
336
+ });
337
+
338
+ const currentPage = ref(1);
339
+ const totalPages = computed(() => imageUrls.value.length);
340
+
341
+ // 当前显示的图片URL
342
+ const currentImageUrl = computed(() => {
343
+ return imageUrls.value[currentPage.value - 1] || '';
344
+ });
345
+
218
346
  const rotation = ref(0);
219
347
  const scale = ref(1);
220
348
  const position = ref({ x: 0, y: 0 });
221
349
  const isPanning = ref(false);
222
350
  const lastPosition = ref({ x: 0, y: 0 });
351
+ const initialAutoFitScale = ref<number | null>(null); // 记录初始自适应缩放比例
352
+ const isUserZooming = ref(false); // 标记用户是否主动缩放
353
+
354
+ // 滚动翻页相关
355
+ let scrollPagingTimer: any = null;
356
+ const isScrollPaging = ref(false); // 标记是否正在进行滚动翻页
223
357
 
224
358
  // 图片和容器引用
225
359
  const containerRef = ref<HTMLElement>();
226
360
  const imageRef = ref<HTMLImageElement>();
227
361
  const textLayerRef = ref<HTMLElement>();
228
- const copyButtonRef = ref<HTMLElement>();
362
+ const annotationButtonRef = ref<HTMLElement>();
229
363
 
230
364
  // 图片尺寸
231
365
  const imageSize = ref({ width: 0, height: 0 });
@@ -253,21 +387,43 @@ const scaledImageSize = computed(() => {
253
387
  // 容器样式,动态设置最小尺寸以支持滚动
254
388
  const containerStyle = computed(() => {
255
389
  const { width, height } = scaledImageSize.value;
256
- const padding = 40; // 左右各20px,共40px
257
390
 
391
+ // 如果启用自适应宽度,不设置固定的 minWidth
392
+ // 让容器自适应内容,避免横向滚动条
393
+ if (props.autoFitWidth) {
394
+ return {
395
+ minWidth: '100%', // 使用 100% 让容器自适应父容器宽度
396
+ minHeight: height > 0 ? `${height}px` : '100%'
397
+ };
398
+ }
399
+
400
+ // 未启用自适应宽度时,使用原始逻辑
258
401
  return {
259
- minWidth: width > 0 ? `${width + padding}px` : '100%',
260
- minHeight: height > 0 ? `${height + padding}px` : '100%'
402
+ minWidth: width > 0 ? `${width}px` : '100%',
403
+ minHeight: height > 0 ? `${height}px` : '100%'
261
404
  };
262
405
  });
263
406
 
264
- // 文本选择相关状态
265
- const showCopyButton = ref(false);
266
- const selectedText = ref('');
267
- const copyButtonStyle = ref<any>({});
407
+ // 批注相关状态
408
+ const showAnnotationPopup = ref(false); // 是否显示批注弹窗
409
+ const annotationPopupStyle = ref<any>({}); // 批注弹窗样式
410
+ const annotationInput = ref(''); // 批注输入内容
411
+ const currentAnnotationBlock = ref<{ bbox: [number, number, number, number]; content: string } | null>(null); // 当前正在添加批注的文本块
412
+ const annotationPopupRef = ref<HTMLElement>(); // 批注弹窗引用
413
+
414
+ // 文本选择相关状态(保留用于兼容)
268
415
  const activeBlockDiv = ref<HTMLElement | null>(null);
269
416
  const isHighlighted = ref(false); // 标识是否为定位点击的高亮
270
417
 
418
+ // 根据当前页码过滤blocksData
419
+ const currentPageBlocksData = computed(() => {
420
+ if (!props.blocksData || props.blocksData.length === 0) {
421
+ return [];
422
+ }
423
+ // 根据当前页码过滤
424
+ return props.blocksData.filter(block => block.pageNo === currentPage.value);
425
+ });
426
+
271
427
  // 隐藏定时器
272
428
  let hideTimer: any = null;
273
429
 
@@ -280,6 +436,9 @@ const rotateImage = direction => {
280
436
  };
281
437
 
282
438
  const zoom = (delta, isWheel = false) => {
439
+ // 用户主动缩放时,标记为手动缩放
440
+ isUserZooming.value = true;
441
+
283
442
  const step = isWheel ? props.wheelStep : props.clickStep; // 滚轮缩放使用更小的步长
284
443
  const newScale = scale.value + delta * step;
285
444
  if (newScale <= props.minScale) {
@@ -305,12 +464,21 @@ const handleWheel = (e: WheelEvent) => {
305
464
  const startPan = (e: MouseEvent) => {
306
465
  // 只在左键点击时启用拖拽
307
466
  if (e.button !== 0) return;
308
- // 如果按住 Ctrl 或 Meta 键,允许拖拽(避免与文本选择冲突)
309
- // 如果没有文本图层数据,或者按住修饰键,允许拖拽
310
- if (props.blocksData && props.blocksData.length > 0 && !e.ctrlKey && !e.metaKey) {
311
- // 有文本图层时,默认不拖拽,允许文本选择
312
- return;
467
+
468
+ // 如果有文本图层数据,默认允许文本选择,不拖拽
469
+ // 只有在按住 Ctrl Meta 键时才允许拖拽
470
+ if (currentPageBlocksData.value && currentPageBlocksData.value.length > 0) {
471
+ // 如果用户正在选择文本,不启用拖拽
472
+ const selection = window.getSelection();
473
+ if (selection && selection.toString().length > 0) {
474
+ return;
475
+ }
476
+ // 如果没有按住修饰键,允许文本选择,不拖拽
477
+ if (!e.ctrlKey && !e.metaKey) {
478
+ return;
479
+ }
313
480
  }
481
+
314
482
  isPanning.value = true;
315
483
  lastPosition.value = { x: e.clientX, y: e.clientY };
316
484
  e.preventDefault(); // 阻止默认行为,避免与滚动冲突
@@ -333,16 +501,237 @@ const pan = (e: MouseEvent) => {
333
501
 
334
502
  const stopPan = () => {
335
503
  isPanning.value = false;
504
+
505
+ // 在自适应宽度模式下,如果用户没有主动缩放,确保缩放比例保持初始值
506
+ if (props.autoFitWidth &&
507
+ !isUserZooming.value &&
508
+ initialAutoFitScale.value !== null &&
509
+ Math.abs(scale.value - initialAutoFitScale.value) > 0.01) {
510
+ // 拖拽操作不应该改变缩放比例,如果被改变了,恢复它
511
+ scale.value = initialAutoFitScale.value;
512
+ }
513
+
514
+ };
515
+
516
+ // 处理鼠标抬起事件(检测文本选择)
517
+ const handleMouseUp = (e: MouseEvent) => {
518
+ // 先执行停止拖拽的逻辑
519
+ stopPan();
520
+
521
+ // 延迟检测文本选择,确保选择已完成
522
+ setTimeout(() => {
523
+ checkTextSelection(e);
524
+ }, 10);
525
+ };
526
+
527
+ // 检测文本选择并显示批注按钮
528
+ const checkTextSelection = (e: MouseEvent) => {
529
+ // 如果正在显示批注弹窗,不执行文本选择检测
530
+ if (currentAnnotationBlock.value) {
531
+ return;
532
+ }
533
+
534
+ const target = e.target as HTMLElement;
535
+ // 如果点击的是批注按钮或批注弹窗,不执行文本选择检测
536
+ if (target.closest('.annotation-button-popup') || target.closest('.annotation-input-popup')) {
537
+ return;
538
+ }
539
+
540
+ const selection = window.getSelection();
541
+
542
+ // 如果没有选中文本,隐藏批注按钮(但不关闭批注输入弹窗)
543
+ if (!selection || selection.toString().trim().length === 0) {
544
+ // 如果点击的不是文本块,且不在批注弹窗内,隐藏批注按钮
545
+ if (!target.closest('.text-block') && !target.closest('.annotation-input-popup')) {
546
+ // 只有在没有打开批注输入弹窗时才隐藏批注按钮
547
+ if (!currentAnnotationBlock.value) {
548
+ showAnnotationPopup.value = false;
549
+ activeBlockDiv.value = null;
550
+ }
551
+ }
552
+ return;
553
+ }
554
+
555
+ // 获取选中的文本
556
+ const selectedText = selection.toString().trim();
557
+ if (!selectedText) {
558
+ return;
559
+ }
560
+
561
+ // 检查选中的文本是否在文本块内
562
+ const range = selection.getRangeAt(0);
563
+ const container = range.commonAncestorContainer;
564
+
565
+ // 向上查找最近的文本块元素
566
+ let textBlockElement: HTMLElement | null = null;
567
+ let node: Node | null = container.nodeType === Node.TEXT_NODE ? container.parentElement : container as HTMLElement;
568
+
569
+ while (node && node !== document.body) {
570
+ if (node instanceof HTMLElement && node.classList.contains('text-block')) {
571
+ textBlockElement = node;
572
+ break;
573
+ }
574
+ node = node.parentElement;
575
+ }
576
+
577
+ if (!textBlockElement) {
578
+ return;
579
+ }
580
+
581
+ // 设置当前激活的文本块
582
+ activeBlockDiv.value = textBlockElement;
583
+
584
+ // 获取选中文本的位置
585
+ const rect = range.getBoundingClientRect();
586
+ const containerRect = containerRef.value?.getBoundingClientRect();
587
+
588
+ if (!containerRect || !containerRef.value) return;
589
+
590
+ // 计算批注按钮的位置
591
+ const buttonWidth = 80;
592
+ const popupWidth = buttonWidth + 8;
593
+ const popupHeight = 38;
594
+ const spacing = 4; // 减少间距,让按钮更靠近文字
595
+
596
+ // 计算相对于容器的坐标(考虑滚动)
597
+ const relativeX = rect.left - containerRect.left + containerRef.value.scrollLeft;
598
+ const relativeY = rect.top - containerRect.top + containerRef.value.scrollTop;
599
+
600
+ // 容器可用空间
601
+ const containerWidth = containerRef.value.clientWidth;
602
+
603
+ // 计算可用空间
604
+ const rectBottom = rect.bottom;
605
+ const rectTop = rect.top;
606
+ const containerBottom = containerRect.bottom;
607
+ const containerTop = containerRect.top;
608
+
609
+ const bottomSpace = containerBottom - rectBottom;
610
+ const topSpace = rectTop - containerTop;
611
+
612
+ const showOnBottom = bottomSpace >= popupHeight + spacing || (bottomSpace > 0 && bottomSpace >= topSpace);
613
+
614
+ // 计算最终位置
615
+ let left: number;
616
+ let top: number;
617
+
618
+ const centerX = relativeX + rect.width / 2;
619
+ left = centerX - popupWidth / 2;
620
+
621
+ if (showOnBottom) {
622
+ top = relativeY + rect.height + spacing;
623
+ } else {
624
+ top = relativeY - popupHeight - spacing;
625
+ }
626
+
627
+ // 边界检查
628
+ const popupTopInViewport = top - containerRef.value.scrollTop + containerRect.top;
629
+ const popupBottomInViewport = popupTopInViewport + popupHeight;
630
+
631
+ if (popupBottomInViewport > containerBottom - spacing) {
632
+ top = relativeY - popupHeight - spacing;
633
+ }
634
+
635
+ const newPopupTopInViewport = top - containerRef.value.scrollTop + containerRect.top;
636
+ if (newPopupTopInViewport < containerTop + spacing) {
637
+ top = relativeY + rect.height + spacing;
638
+ }
639
+
640
+ // 确保不超出容器边界
641
+ if (left + popupWidth > containerWidth - spacing) {
642
+ left = containerWidth - popupWidth - spacing;
643
+ }
644
+ if (left < spacing) {
645
+ left = spacing;
646
+ }
647
+
648
+ annotationPopupStyle.value = {
649
+ left: `${left}px`,
650
+ top: `${top}px`
651
+ };
652
+
653
+ // 显示批注按钮
654
+ showAnnotationPopup.value = true;
655
+ };
656
+
657
+ // 翻页相关方法
658
+ const goToPreviousPage = () => {
659
+ if (currentPage.value > 1) {
660
+ switchToPage(currentPage.value - 1);
661
+ }
662
+ };
663
+
664
+ const goToNextPage = () => {
665
+ if (currentPage.value < totalPages.value) {
666
+ switchToPage(currentPage.value + 1);
667
+ }
668
+ };
669
+
670
+ const switchToPage = (page: number) => {
671
+ if (page < 1 || page > totalPages.value) {
672
+ return;
673
+ }
674
+
675
+ // 切换页码前重置状态(但不重置缩放,等图片加载后再设置)
676
+ rotation.value = 0;
677
+ position.value = { x: 0, y: 0 };
678
+ isUserZooming.value = false;
679
+
680
+ // 重置文本选择状态和批注状态
681
+ showAnnotationPopup.value = false;
682
+ currentAnnotationBlock.value = null;
683
+ annotationInput.value = '';
684
+ activeBlockDiv.value = null;
685
+ isHighlighted.value = false;
686
+
687
+ // 先更新页码,这样图片会立即切换
688
+ currentPage.value = page;
689
+
690
+ // 触发页码变化事件
691
+ emit('page-change', page, totalPages.value);
692
+
693
+ // 确保滚动位置在顶部(在滚动翻页时已经设置,这里作为保险)
694
+ 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();
714
+ }
715
+ });
336
716
  };
337
717
 
338
718
  const reset = () => {
339
719
  rotation.value = 0;
340
- scale.value = 1;
720
+
721
+ // 如果启用自适应宽度且有初始缩放比例,恢复到初始缩放比例
722
+ if (props.autoFitWidth && initialAutoFitScale.value !== null) {
723
+ scale.value = initialAutoFitScale.value;
724
+ isUserZooming.value = false;
725
+ } else {
726
+ scale.value = 1;
727
+ }
728
+
341
729
  position.value = { x: 0, y: 0 };
342
730
 
343
- // 重置文本选择状态
344
- showCopyButton.value = false;
345
- selectedText.value = '';
731
+ // 重置文本选择状态和批注状态
732
+ showAnnotationPopup.value = false;
733
+ currentAnnotationBlock.value = null;
734
+ annotationInput.value = '';
346
735
  activeBlockDiv.value = null;
347
736
  isHighlighted.value = false;
348
737
  };
@@ -351,6 +740,38 @@ const original = () => {
351
740
  emit('original');
352
741
  };
353
742
 
743
+ // 计算自适应宽度的缩放比例
744
+ const calculateAutoFitScale = () => {
745
+ if (!props.autoFitWidth || !containerRef.value || imageSize.value.width === 0) {
746
+ return 1;
747
+ }
748
+
749
+ const containerRect = containerRef.value.getBoundingClientRect();
750
+ // 使用容器的完整宽度,但预留足够空间避免因浏览器渲染误差导致横向滚动条
751
+ // 预留 4px 以确保不会因为任何渲染误差导致滚动条
752
+ const containerWidth = containerRect.width - 4;
753
+
754
+ if (containerWidth <= 0) {
755
+ return 1;
756
+ }
757
+
758
+ // 考虑旋转角度,如果旋转了90度或270度,需要交换宽高
759
+ const normalizedRotation = ((rotation.value % 360) + 360) % 360;
760
+ const isRotated = normalizedRotation === 90 || normalizedRotation === 270;
761
+
762
+ const imageWidth = isRotated ? imageSize.value.height : imageSize.value.width;
763
+
764
+ if (imageWidth <= 0) {
765
+ return 1;
766
+ }
767
+
768
+ // 计算缩放比例,使图片宽度完全适应容器宽度
769
+ const calculatedScale = containerWidth / imageWidth;
770
+
771
+ // 确保缩放比例在允许的范围内
772
+ return Math.max(props.minScale, Math.min(props.maxScale, calculatedScale));
773
+ };
774
+
354
775
  // 图片加载完成处理
355
776
  const onImageLoad = (event: Event) => {
356
777
  const img = event.target as HTMLImageElement;
@@ -358,9 +779,32 @@ const onImageLoad = (event: Event) => {
358
779
  width: img.naturalWidth,
359
780
  height: img.naturalHeight
360
781
  };
782
+
783
+ // 如果启用自适应宽度,计算并设置初始缩放比例
784
+ if (props.autoFitWidth) {
785
+ // 重置用户缩放标记
786
+ isUserZooming.value = false;
787
+
788
+ // 使用双重 nextTick 确保容器尺寸已确定
789
+ nextTick(() => {
790
+ nextTick(() => {
791
+ // 添加小延迟确保容器完全渲染
792
+ setTimeout(() => {
793
+ const autoScale = calculateAutoFitScale();
794
+ if (autoScale !== 1 && autoScale > 0) {
795
+ scale.value = autoScale;
796
+ initialAutoFitScale.value = autoScale; // 记录初始自适应缩放比例
797
+ }
798
+ }, 50);
799
+ });
800
+ });
801
+ }
802
+
361
803
  emit('load', {
362
804
  width: img.naturalWidth,
363
- height: img.naturalHeight
805
+ height: img.naturalHeight,
806
+ page: currentPage.value,
807
+ totalPages: totalPages.value
364
808
  });
365
809
 
366
810
  // 图片加载完成后,渲染文本图层
@@ -369,6 +813,62 @@ const onImageLoad = (event: Event) => {
369
813
  });
370
814
  };
371
815
 
816
+ /**
817
+ * 准确计算文本的字体大小
818
+ * 使用二分查找算法,通过 Canvas 测量文本宽度来找到最合适的字体大小
819
+ *
820
+ * @param text 文本内容
821
+ * @param targetWidth 目标宽度(OCR 边界框的宽度)
822
+ * @param targetHeight 目标高度(OCR 边界框的高度)
823
+ * @returns 计算出的字体大小(px)
824
+ */
825
+ const calculateFontSize = (
826
+ text: string,
827
+ targetWidth: number,
828
+ targetHeight: number,
829
+ ): number => {
830
+ // 创建一个离屏 Canvas 用于测量文本
831
+ const canvas = document.createElement("canvas");
832
+ const ctx = canvas.getContext("2d");
833
+
834
+ if (!ctx) {
835
+ // 如果无法创建 context,使用简单估算
836
+ return targetHeight * 0.8;
837
+ }
838
+
839
+ // 二分查找的范围:最小字体和最大字体
840
+ let minSize = 1;
841
+ let maxSize = targetHeight * 2; // 最大不超过高度的2倍
842
+ let bestSize = targetHeight * 0.8; // 默认值
843
+
844
+ // 二分查找最合适的字体大小(最多迭代20次)
845
+ for (let i = 0; i < 20; i++) {
846
+ const midSize = (minSize + maxSize) / 2;
847
+
848
+ // 使用系统默认字体测量(通常是 Arial, sans-serif)
849
+ ctx.font = `${midSize}px Arial, sans-serif`;
850
+ const metrics = ctx.measureText(text);
851
+ const measuredWidth = metrics.width;
852
+
853
+ // 如果测量宽度接近目标宽度(误差在5%以内),认为找到了
854
+ if (Math.abs(measuredWidth - targetWidth) / targetWidth < 0.05) {
855
+ bestSize = midSize;
856
+ break;
857
+ }
858
+
859
+ // 调整搜索范围
860
+ if (measuredWidth < targetWidth) {
861
+ minSize = midSize; // 字体太小,增大下限
862
+ bestSize = midSize; // 更新最佳值
863
+ } else {
864
+ maxSize = midSize; // 字体太大,减小上限
865
+ }
866
+ }
867
+
868
+ // 限制字体大小在合理范围内(不小于6px,不大于目标高度的1.5倍)
869
+ return Math.max(6, Math.min(bestSize, targetHeight * 1.5));
870
+ };
871
+
372
872
  /**
373
873
  * 渲染文本图层(使用 blocksData 数据)
374
874
  */
@@ -380,8 +880,10 @@ const renderTextLayer = () => {
380
880
  return;
381
881
  }
382
882
 
883
+ console.log('renderTextLayer', currentPageBlocksData.value);
884
+
383
885
  // 如果没有提供分块数据,跳过渲染
384
- if (!props.blocksData || props.blocksData.length === 0) {
886
+ if (!currentPageBlocksData.value || currentPageBlocksData.value.length === 0) {
385
887
  textLayer.innerHTML = '';
386
888
  return;
387
889
  }
@@ -394,21 +896,23 @@ const renderTextLayer = () => {
394
896
  // 清空文本图层
395
897
  textLayer.innerHTML = '';
396
898
 
397
- // 使用 blocksData 创建可交互的块
398
- props.blocksData.forEach((block, index) => {
399
- const { blockLabel, blockContent, blockBbox } = block;
899
+ // 使用当前页码的 blocksData 创建可交互的块
900
+ currentPageBlocksData.value.forEach((block, index) => {
901
+ const { content, bbox } = block;
400
902
 
401
- // blockBbox 格式: [x1, y1, x2, y2]
402
- const [x1, y1, x2, y2] = blockBbox;
903
+ // bbox 格式: [x1, y1, x2, y2]
904
+ const [x1, y1, x2, y2] = bbox;
403
905
  const width = x2 - x1;
404
906
  const height = y2 - y1;
405
907
 
908
+ // 自动计算字体大小
909
+ const calculatedFontSize = calculateFontSize(content, width, height);
910
+
406
911
  // 创建文本块
407
912
  const blockDiv = document.createElement('div');
408
913
  blockDiv.className = 'text-block';
409
- blockDiv.dataset.text = blockContent;
410
- blockDiv.dataset.label = blockLabel;
411
- blockDiv.dataset.bbox = JSON.stringify(blockBbox);
914
+ blockDiv.dataset.text = content;
915
+ blockDiv.dataset.bbox = JSON.stringify(bbox);
412
916
 
413
917
  // 设置基础样式
414
918
  blockDiv.style.position = 'absolute';
@@ -416,45 +920,72 @@ const renderTextLayer = () => {
416
920
  blockDiv.style.top = `${y1}px`;
417
921
  blockDiv.style.width = `${width}px`;
418
922
  blockDiv.style.height = `${height}px`;
419
- blockDiv.style.zIndex = '10';
420
- blockDiv.style.cursor = 'pointer';
421
- blockDiv.style.borderRadius = '2px';
422
- blockDiv.style.transition = 'all 0.2s ease';
423
- blockDiv.style.backgroundColor = 'transparent';
424
-
425
- // Hover 和点击事件
426
- blockDiv.addEventListener('mouseenter', (e) => {
427
- // 取消之前的隐藏定时器
428
- if (hideTimer) {
429
- clearTimeout(hideTimer);
430
- hideTimer = null;
431
- }
432
-
433
- // 如果有之前激活的文本块,先恢复其样式
434
- if (activeBlockDiv.value && activeBlockDiv.value !== blockDiv) {
435
- if (isHighlighted.value) {
436
- isHighlighted.value = false;
437
- }
438
- activeBlockDiv.value.style.backgroundColor = 'transparent';
439
- activeBlockDiv.value.style.boxShadow = 'none';
440
- }
441
-
923
+ blockDiv.style.zIndex = '20'; // 确保文本块在图片上方
924
+ blockDiv.style.cursor = 'text'; // 改为文本选择光标
925
+ // 设置文本内容(使用 textContent 而不是 innerHTML,避免 XSS 风险)
926
+ blockDiv.textContent = content;
927
+ // 设置文本样式,确保可以选择和显示
928
+ blockDiv.style.color = 'red'; // 红色文字
929
+ 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'; // 确保可见
936
+ // 允许文本选择
937
+ blockDiv.style.userSelect = 'text';
938
+ blockDiv.style.webkitUserSelect = 'text';
939
+ blockDiv.style.mozUserSelect = 'text';
940
+ blockDiv.style.msUserSelect = 'text';
941
+ // 允许指针事件,但不阻止文本选择
942
+ blockDiv.style.pointerEvents = 'auto';
943
+
944
+ // 右键菜单:显示批注按钮
945
+ blockDiv.addEventListener('contextmenu', (e) => {
946
+ e.preventDefault();
442
947
  // 设置当前文本块为激活状态
443
948
  activeBlockDiv.value = blockDiv;
444
- isHighlighted.value = false; // 鼠标悬停时清除定位高亮标志
445
-
446
- // 直接设置 hover 样式
447
- blockDiv.style.backgroundColor = 'var(--s-color-brand-primary-transparent-3, rgba(0, 102, 255, .15))';
448
- blockDiv.style.boxShadow = '0 0 0 2px rgba(30, 144, 255, 0.6)';
449
-
450
- // 显示复制按钮
451
- showCopyButtonForBlock(e, blockDiv);
949
+ // 显示批注按钮
950
+ showAnnotationButtonForBlock(e, blockDiv);
452
951
  });
453
952
 
454
- blockDiv.addEventListener('mouseleave', () => {
455
- // 延迟隐藏,给用户时间移动到复制按钮
456
- hideCopyButtonAndHighlight();
457
- });
953
+ // 检查是否有已有批注,如果有则显示批注标记
954
+ const existingAnnotation = getAnnotationForBlock(bbox);
955
+ if (existingAnnotation) {
956
+ // 添加批注标记样式类
957
+ blockDiv.classList.add('has-annotation');
958
+ blockDiv.title = `已有批注: ${existingAnnotation.content}`;
959
+ // 直接设置样式,确保生效(内联样式优先级高于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
+
966
+ // 创建批注图标标记
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';
984
+ blockDiv.appendChild(annotationMarker);
985
+ } else {
986
+ // 没有批注时设置为透明背景
987
+ blockDiv.style.backgroundColor = 'transparent';
988
+ }
458
989
 
459
990
  textLayer.appendChild(blockDiv);
460
991
  });
@@ -464,92 +995,122 @@ const renderTextLayer = () => {
464
995
  };
465
996
 
466
997
  /**
467
- * 显示文本块的复制按钮
998
+ * 获取文本块对应的批注
468
999
  */
469
- const showCopyButtonForBlock = (event: MouseEvent, blockDiv: HTMLElement) => {
470
- const text = blockDiv.dataset.text || '';
471
- if (!text) return;
1000
+ const getAnnotationForBlock = (bbox: [number, number, number, number]): AnnotationInfo | null => {
1001
+ if (!props.annotations || props.annotations.length === 0) {
1002
+ return null;
1003
+ }
1004
+
1005
+ 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;
1016
+ };
472
1017
 
473
- selectedText.value = text;
1018
+ /**
1019
+ * 显示文本块的批注按钮
1020
+ */
1021
+ const showAnnotationButtonForBlock = (event: MouseEvent, blockDiv: HTMLElement) => {
1022
+ const text = blockDiv.dataset.text || '';
1023
+ const bboxStr = blockDiv.dataset.bbox || '';
1024
+ if (!text || !bboxStr) return;
474
1025
 
475
- const rect = blockDiv.getBoundingClientRect();
476
- const containerRect = containerRef.value?.getBoundingClientRect();
1026
+ try {
1027
+ const bbox = JSON.parse(bboxStr) as [number, number, number, number];
1028
+
1029
+ const rect = blockDiv.getBoundingClientRect();
1030
+ const containerRect = containerRef.value?.getBoundingClientRect();
477
1031
 
478
- if (!containerRect || !containerRef.value) return;
1032
+ if (!containerRect || !containerRef.value) return;
479
1033
 
480
- // 计算复制按钮的位置
481
- const buttonCount = 1;
482
- const buttonWidth = 80;
483
- const popupWidth = buttonCount * buttonWidth + 8;
484
- const popupHeight = 38;
485
- const spacing = 8;
1034
+ // 计算批注按钮的位置
1035
+ const buttonWidth = 80;
1036
+ const popupWidth = buttonWidth + 8;
1037
+ const popupHeight = 38;
1038
+ const spacing = 8;
486
1039
 
487
- // 计算相对于容器的坐标(考虑滚动)
488
- const relativeX = rect.left - containerRect.left + containerRef.value.scrollLeft;
489
- const relativeY = rect.top - containerRect.top + containerRef.value.scrollTop;
1040
+ // 计算相对于容器的坐标(考虑滚动)
1041
+ const relativeX = rect.left - containerRect.left + containerRef.value.scrollLeft;
1042
+ const relativeY = rect.top - containerRect.top + containerRef.value.scrollTop;
490
1043
 
491
- // 容器可用空间
492
- const containerWidth = containerRef.value.clientWidth;
1044
+ // 容器可用空间
1045
+ const containerWidth = containerRef.value.clientWidth;
493
1046
 
494
- // 计算可用空间
495
- const rectBottom = rect.bottom;
496
- const rectTop = rect.top;
497
- const containerBottom = containerRect.bottom;
498
- const containerTop = containerRect.top;
1047
+ // 计算可用空间
1048
+ const rectBottom = rect.bottom;
1049
+ const rectTop = rect.top;
1050
+ const containerBottom = containerRect.bottom;
1051
+ const containerTop = containerRect.top;
499
1052
 
500
- const bottomSpace = containerBottom - rectBottom;
501
- const topSpace = rectTop - containerTop;
1053
+ const bottomSpace = containerBottom - rectBottom;
1054
+ const topSpace = rectTop - containerTop;
502
1055
 
503
- const showOnBottom = bottomSpace >= popupHeight + spacing || (bottomSpace > 0 && bottomSpace >= topSpace);
1056
+ const showOnBottom = bottomSpace >= popupHeight + spacing || (bottomSpace > 0 && bottomSpace >= topSpace);
504
1057
 
505
- // 计算最终位置
506
- let left: number;
507
- let top: number;
1058
+ // 计算最终位置
1059
+ let left: number;
1060
+ let top: number;
508
1061
 
509
- const centerX = relativeX + rect.width / 2;
510
- left = centerX - popupWidth / 2;
1062
+ const centerX = relativeX + rect.width / 2;
1063
+ left = centerX - popupWidth / 2;
511
1064
 
512
- if (showOnBottom) {
513
- top = relativeY + rect.height + spacing;
514
- } else {
515
- top = relativeY - popupHeight - spacing;
516
- }
1065
+ if (showOnBottom) {
1066
+ top = relativeY + rect.height + spacing;
1067
+ } else {
1068
+ top = relativeY - popupHeight - spacing;
1069
+ }
517
1070
 
518
- // 边界检查
519
- const popupTopInViewport = top - containerRef.value.scrollTop + containerRect.top;
520
- const popupBottomInViewport = popupTopInViewport + popupHeight;
1071
+ // 边界检查
1072
+ const popupTopInViewport = top - containerRef.value.scrollTop + containerRect.top;
1073
+ const popupBottomInViewport = popupTopInViewport + popupHeight;
521
1074
 
522
- if (popupBottomInViewport > containerBottom - spacing) {
523
- top = relativeY - popupHeight - spacing;
524
- }
1075
+ if (popupBottomInViewport > containerBottom - spacing) {
1076
+ top = relativeY - popupHeight - spacing;
1077
+ }
525
1078
 
526
- const newPopupTopInViewport = top - containerRef.value.scrollTop + containerRect.top;
527
- if (newPopupTopInViewport < containerTop + spacing) {
528
- top = relativeY + rect.height + spacing;
529
- }
1079
+ const newPopupTopInViewport = top - containerRef.value.scrollTop + containerRect.top;
1080
+ if (newPopupTopInViewport < containerTop + spacing) {
1081
+ top = relativeY + rect.height + spacing;
1082
+ }
530
1083
 
531
- // 确保不超出容器边界
532
- if (left + popupWidth > containerWidth - spacing) {
533
- left = containerWidth - popupWidth - spacing;
534
- }
535
- if (left < spacing) {
536
- left = spacing;
537
- }
1084
+ // 确保不超出容器边界
1085
+ if (left + popupWidth > containerWidth - spacing) {
1086
+ left = containerWidth - popupWidth - spacing;
1087
+ }
1088
+ if (left < spacing) {
1089
+ left = spacing;
1090
+ }
538
1091
 
539
- copyButtonStyle.value = {
540
- left: `${left}px`,
541
- top: `${top}px`
542
- };
1092
+ annotationPopupStyle.value = {
1093
+ left: `${left}px`,
1094
+ top: `${top}px`
1095
+ };
543
1096
 
544
- showCopyButton.value = true;
1097
+ showAnnotationPopup.value = true;
1098
+ } catch (error) {
1099
+ console.error('解析bbox失败:', error);
1100
+ }
545
1101
  };
546
1102
 
547
1103
  /**
548
- * 隐藏复制按钮并移除文本块高亮
1104
+ * 隐藏批注按钮
549
1105
  */
550
- const hideCopyButtonAndHighlight = () => {
1106
+ const hideAnnotationButton = () => {
1107
+ // 如果正在输入批注,不自动隐藏
1108
+ if (currentAnnotationBlock.value) {
1109
+ return;
1110
+ }
1111
+
551
1112
  hideTimer = setTimeout(() => {
552
- showCopyButton.value = false;
1113
+ showAnnotationPopup.value = false;
553
1114
 
554
1115
  if (activeBlockDiv.value && !isHighlighted.value) {
555
1116
  activeBlockDiv.value.style.backgroundColor = 'transparent';
@@ -560,9 +1121,9 @@ const hideCopyButtonAndHighlight = () => {
560
1121
  };
561
1122
 
562
1123
  /**
563
- * 取消隐藏(鼠标进入复制按钮时调用)
1124
+ * 取消隐藏(鼠标进入批注按钮时调用)
564
1125
  */
565
- const cancelHideCopyButton = () => {
1126
+ const cancelHideAnnotationButton = () => {
566
1127
  if (hideTimer) {
567
1128
  clearTimeout(hideTimer);
568
1129
  hideTimer = null;
@@ -570,129 +1131,320 @@ const cancelHideCopyButton = () => {
570
1131
  };
571
1132
 
572
1133
  /**
573
- * 将HTML表格转换为纯文本格式
1134
+ * 打开批注输入框
574
1135
  */
575
- const convertHtmlTableToText = (htmlString: string): string => {
576
- if (!/<[^>]+>/g.test(htmlString)) {
577
- return htmlString;
1136
+ const openAnnotationInput = (e?: Event) => {
1137
+ // 阻止事件冒泡,避免触发 handleMouseUp
1138
+ if (e) {
1139
+ e.stopPropagation();
578
1140
  }
579
-
580
- try {
581
- const tempDiv = document.createElement('div');
582
- tempDiv.innerHTML = htmlString;
583
-
584
- const tables = tempDiv.querySelectorAll('table');
585
-
586
- if (tables.length === 0) {
587
- return tempDiv.textContent || htmlString;
1141
+
1142
+ // 获取选中的文本
1143
+ const selection = window.getSelection();
1144
+ let selectedText = '';
1145
+
1146
+ if (selection && selection.toString().trim().length > 0) {
1147
+ selectedText = selection.toString().trim();
1148
+ } else if (activeBlockDiv.value) {
1149
+ selectedText = activeBlockDiv.value.dataset.text || '';
1150
+ }
1151
+
1152
+ if (!selectedText && !activeBlockDiv.value) return;
1153
+
1154
+ // 如果有激活的文本块,使用文本块的 bbox
1155
+ let bbox: [number, number, number, number] | null = null;
1156
+ if (activeBlockDiv.value) {
1157
+ const bboxStr = activeBlockDiv.value.dataset.bbox || '';
1158
+ try {
1159
+ bbox = JSON.parse(bboxStr) as [number, number, number, number];
1160
+ } catch (error) {
1161
+ console.error('解析 bbox 失败:', error);
588
1162
  }
589
-
590
- let result = '';
591
-
592
- tables.forEach((table, tableIndex) => {
593
- if (tableIndex > 0) {
594
- result += '\n\n';
595
- }
596
-
597
- const rows = table.querySelectorAll('tr');
598
-
599
- rows.forEach((row) => {
600
- const cells = row.querySelectorAll('td, th');
601
- const cellTexts: string[] = [];
602
-
603
- cells.forEach((cell) => {
604
- const text = (cell.textContent || '').trim();
605
- cellTexts.push(text);
606
- });
607
-
608
- if (cellTexts.length > 0) {
609
- result += `| ${cellTexts.join(' | ')} |\n`;
610
- }
611
- });
612
- });
613
-
614
- return result.trim();
615
- } catch (error) {
616
- console.error('HTML表格转换失败:', error);
617
- return htmlString.replace(/<[^>]+>/g, '');
618
1163
  }
1164
+
1165
+ // 如果没有 bbox,尝试从选中文本的位置计算
1166
+ if (!bbox && selection && selection.rangeCount > 0) {
1167
+ const range = selection.getRangeAt(0);
1168
+ const rect = range.getBoundingClientRect();
1169
+ const containerRect = containerRef.value?.getBoundingClientRect();
1170
+
1171
+ if (containerRect && containerRef.value) {
1172
+ const relativeX = rect.left - containerRect.left + containerRef.value.scrollLeft;
1173
+ const relativeY = rect.top - containerRect.top + containerRef.value.scrollTop;
1174
+ bbox = [
1175
+ relativeX,
1176
+ relativeY,
1177
+ relativeX + rect.width,
1178
+ relativeY + rect.height
1179
+ ];
1180
+ }
1181
+ }
1182
+
1183
+ if (!bbox) {
1184
+ Message.warning('无法确定文本位置');
1185
+ return;
1186
+ }
1187
+
1188
+ // 检查是否已有批注
1189
+ const existingAnnotation = getAnnotationForBlock(bbox);
1190
+ if (existingAnnotation) {
1191
+ annotationInput.value = existingAnnotation.content;
1192
+ } else {
1193
+ annotationInput.value = '';
1194
+ }
1195
+
1196
+ currentAnnotationBlock.value = {
1197
+ bbox,
1198
+ content: selectedText
1199
+ };
1200
+
1201
+ // 确保弹窗显示
1202
+ showAnnotationPopup.value = true;
1203
+
1204
+ // 重新计算弹窗位置(输入框更大)
1205
+ nextTick(() => {
1206
+ adjustAnnotationPopupPosition();
1207
+ });
619
1208
  };
620
1209
 
621
1210
  /**
622
- * 复制选中的文本
1211
+ * 调整批注输入弹窗位置
623
1212
  */
624
- const copySelectedText = async () => {
625
- try {
626
- const textToCopy = convertHtmlTableToText(selectedText.value);
627
-
628
- if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
629
- try {
630
- await navigator.clipboard.writeText(textToCopy);
631
- } catch (clipboardError) {
632
- console.warn('Clipboard API 失败,使用降级方案:', clipboardError);
633
- fallbackCopyText(textToCopy);
634
- }
1213
+ const adjustAnnotationPopupPosition = () => {
1214
+ const containerRect = containerRef.value?.getBoundingClientRect();
1215
+ const popup = annotationPopupRef.value;
1216
+
1217
+ if (!containerRect || !containerRef.value) {
1218
+ return;
1219
+ }
1220
+
1221
+ const popupWidth = popup?.getBoundingClientRect().width || 320; // 默认宽度
1222
+ const popupHeight = popup?.getBoundingClientRect().height || 200; // 默认高度
1223
+ const spacing = 4; // 减少间距,让弹窗更靠近文字
1224
+
1225
+ // 优先使用文本块位置(和批注按钮位置计算逻辑一致)
1226
+ let left: number;
1227
+ let top: number;
1228
+
1229
+ if (activeBlockDiv.value) {
1230
+ // 使用文本块位置,和批注按钮使用相同的计算逻辑
1231
+ 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
+
1235
+ // 计算可用空间
1236
+ const rectBottom = rect.bottom;
1237
+ const rectTop = rect.top;
1238
+ const containerBottom = containerRect.bottom;
1239
+ const containerTop = containerRect.top;
1240
+
1241
+ const bottomSpace = containerBottom - rectBottom;
1242
+ const topSpace = rectTop - containerTop;
1243
+
1244
+ // 判断弹窗显示在文字下方还是上方
1245
+ const showOnBottom = bottomSpace >= popupHeight + spacing || (bottomSpace > 0 && bottomSpace >= topSpace);
1246
+
1247
+ // 计算位置(和批注按钮位置计算一致)
1248
+ const centerX = relativeX + rect.width / 2;
1249
+ left = centerX - popupWidth / 2;
1250
+
1251
+ if (showOnBottom) {
1252
+ top = relativeY + rect.height + spacing;
635
1253
  } else {
636
- fallbackCopyText(textToCopy);
1254
+ top = relativeY - popupHeight - spacing;
637
1255
  }
638
-
639
- showCopyButton.value = false;
640
-
641
- if (activeBlockDiv.value && !isHighlighted.value) {
642
- activeBlockDiv.value.style.backgroundColor = 'transparent';
643
- activeBlockDiv.value.style.boxShadow = 'none';
644
- activeBlockDiv.value = null;
1256
+
1257
+ // 边界检查
1258
+ const popupTopInViewport = top - containerRef.value.scrollTop + containerRect.top;
1259
+ const popupBottomInViewport = popupTopInViewport + popupHeight;
1260
+
1261
+ if (popupBottomInViewport > containerBottom - spacing) {
1262
+ top = relativeY - popupHeight - spacing;
645
1263
  }
646
-
647
- window.getSelection()?.removeAllRanges();
648
- } catch (error) {
649
- console.error('复制失败:', error);
650
- Message.error('复制失败,请手动选择并复制文本');
1264
+
1265
+ const newPopupTopInViewport = top - containerRef.value.scrollTop + containerRect.top;
1266
+ if (newPopupTopInViewport < containerTop + spacing) {
1267
+ top = relativeY + rect.height + spacing;
1268
+ }
1269
+ } else {
1270
+ // 如果没有文本块,使用当前按钮位置(作为后备方案)
1271
+ const currentStyle = annotationPopupStyle.value;
1272
+ if (currentStyle && currentStyle.left && currentStyle.top) {
1273
+ left = parseFloat(currentStyle.left.toString().replace('px', ''));
1274
+ top = parseFloat(currentStyle.top.toString().replace('px', ''));
1275
+ } else {
1276
+ return;
1277
+ }
1278
+ }
1279
+
1280
+ // 确保不超出容器边界
1281
+ const containerWidth = containerRef.value.clientWidth;
1282
+ if (left + popupWidth > containerWidth - spacing) {
1283
+ left = containerWidth - popupWidth - spacing;
651
1284
  }
1285
+ if (left < spacing) {
1286
+ left = spacing;
1287
+ }
1288
+
1289
+ annotationPopupStyle.value = {
1290
+ left: `${left}px`,
1291
+ top: `${top}px`
1292
+ };
652
1293
  };
653
1294
 
654
1295
  /**
655
- * 降级复制方案
1296
+ * 关闭批注输入框
656
1297
  */
657
- const fallbackCopyText = (text: string) => {
658
- const textarea = document.createElement('textarea');
659
- textarea.value = text;
660
- textarea.style.position = 'fixed';
661
- textarea.style.top = '-9999px';
662
- textarea.style.left = '-9999px';
663
- textarea.style.opacity = '0';
664
- textarea.style.pointerEvents = 'none';
1298
+ const closeAnnotationInput = () => {
1299
+ currentAnnotationBlock.value = null;
1300
+ annotationInput.value = '';
1301
+ showAnnotationPopup.value = false;
1302
+ };
665
1303
 
666
- document.body.appendChild(textarea);
1304
+ /**
1305
+ * 处理批注弹窗鼠标离开事件
1306
+ */
1307
+ const handleAnnotationPopupLeave = (e: MouseEvent) => {
1308
+ // 检查鼠标是否移动到弹窗内部的其他元素
1309
+ const relatedTarget = e.relatedTarget as HTMLElement;
1310
+ if (relatedTarget && annotationPopupRef.value?.contains(relatedTarget)) {
1311
+ return;
1312
+ }
1313
+
1314
+ // 延迟关闭,给用户时间操作
1315
+ hideTimer = setTimeout(() => {
1316
+ closeAnnotationInput();
1317
+ }, 200);
1318
+ };
667
1319
 
668
- try {
669
- textarea.focus();
670
- textarea.select();
671
- textarea.setSelectionRange(0, textarea.value.length);
672
- const successful = document.execCommand('copy');
673
- if (!successful) {
674
- throw new Error('execCommand("copy") 返回 false');
675
- }
676
- } finally {
677
- document.body.removeChild(textarea);
1320
+ /**
1321
+ * 保存批注
1322
+ */
1323
+ const saveAnnotation = () => {
1324
+ if (!currentAnnotationBlock.value || !annotationInput.value.trim()) {
1325
+ return;
1326
+ }
1327
+
1328
+ const { bbox, content } = currentAnnotationBlock.value;
1329
+ const annotationContent = annotationInput.value.trim();
1330
+
1331
+ // 检查是否已有批注
1332
+ const existingAnnotation = getAnnotationForBlock(bbox);
1333
+
1334
+ const annotation: AnnotationInfo = {
1335
+ id: existingAnnotation?.id || `annotation_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
1336
+ blockBbox: bbox,
1337
+ blockContent: content,
1338
+ blockPage: currentPage.value,
1339
+ content: annotationContent,
1340
+ createTime: existingAnnotation?.createTime || Date.now()
1341
+ };
1342
+
1343
+ if (existingAnnotation) {
1344
+ // 更新已有批注
1345
+ emit('annotation-update', annotation);
1346
+ Message.success('批注已更新');
1347
+ } else {
1348
+ // 添加新批注
1349
+ emit('annotation-add', annotation);
1350
+ Message.success('批注已添加');
678
1351
  }
1352
+
1353
+ // 关闭输入框
1354
+ closeAnnotationInput();
1355
+
1356
+ // 重新渲染文本图层以显示批注标记
1357
+ nextTick(() => {
1358
+ renderTextLayer();
1359
+ });
679
1360
  };
680
1361
 
681
1362
  /**
682
- * 处理滚动事件(隐藏复制按钮和文本块高亮)
1363
+ * 处理滚动事件(隐藏批注按钮和文本块高亮,以及滚动翻页)
683
1364
  */
684
- const handleScroll = () => {
1365
+ const handleScroll = (e: Event) => {
685
1366
  if (hideTimer) {
686
1367
  clearTimeout(hideTimer);
687
1368
  }
688
1369
 
689
- showCopyButton.value = false;
690
-
691
- if (activeBlockDiv.value && !isHighlighted.value) {
692
- activeBlockDiv.value.style.backgroundColor = 'transparent';
693
- activeBlockDiv.value.style.boxShadow = 'none';
1370
+ // 如果正在输入批注,不关闭
1371
+ if (!currentAnnotationBlock.value) {
1372
+ showAnnotationPopup.value = false;
694
1373
  activeBlockDiv.value = null;
695
1374
  }
1375
+
1376
+ // 在自适应宽度模式下,滚动操作不应该改变缩放比例
1377
+ // 如果缩放比例被意外改变,恢复它
1378
+ if (props.autoFitWidth &&
1379
+ !isUserZooming.value &&
1380
+ initialAutoFitScale.value !== null &&
1381
+ Math.abs(scale.value - initialAutoFitScale.value) > 0.01) {
1382
+ scale.value = initialAutoFitScale.value;
1383
+ }
1384
+
1385
+ // 滚动翻页功能
1386
+ if (props.enableScrollPaging && totalPages.value > 1 && !isScrollPaging.value) {
1387
+ handleScrollPaging(e);
1388
+ }
1389
+ };
1390
+
1391
+ /**
1392
+ * 处理滚动翻页
1393
+ */
1394
+ const handleScrollPaging = (e: Event) => {
1395
+ const container = e.target as HTMLElement;
1396
+ if (!container) return;
1397
+
1398
+ // 如果正在翻页中,直接返回
1399
+ if (isScrollPaging.value) {
1400
+ return;
1401
+ }
1402
+
1403
+ // 清除之前的定时器
1404
+ if (scrollPagingTimer) {
1405
+ clearTimeout(scrollPagingTimer);
1406
+ }
1407
+
1408
+ // 使用防抖,避免频繁触发(增加延迟时间减少卡顿)
1409
+ scrollPagingTimer = setTimeout(() => {
1410
+ // 再次检查是否正在翻页
1411
+ if (isScrollPaging.value) {
1412
+ return;
1413
+ }
1414
+
1415
+ const scrollTop = container.scrollTop;
1416
+ const scrollHeight = container.scrollHeight;
1417
+ const clientHeight = container.clientHeight;
1418
+
1419
+ // 滚动到底部(留 100px 的缓冲区,避免误触发)
1420
+ if (scrollTop + clientHeight >= scrollHeight - 100) {
1421
+ 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);
1431
+ }
1432
+ }
1433
+ // 滚动到顶部(留 100px 的缓冲区)
1434
+ else if (scrollTop <= 100) {
1435
+ 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);
1445
+ }
1446
+ }
1447
+ }, 200); // 增加防抖时间到 200ms,减少卡顿
696
1448
  };
697
1449
 
698
1450
  /**
@@ -812,10 +1564,7 @@ const jumpToPosition = (
812
1564
  if (success) {
813
1565
  // 高亮成功,触发事件
814
1566
  if (emitEvent) {
815
- const matchedElement = activeBlockDiv.value;
816
- const label = matchedElement?.dataset.label || '';
817
- const type = label === 'seal' ? 'seal' : 'block';
818
- emit('position-jump', bbox, type);
1567
+ emit('position-jump', bbox, 'block');
819
1568
  }
820
1569
  } else if (retryCount < maxRetries) {
821
1570
  retryCount++;
@@ -842,6 +1591,44 @@ watch(
842
1591
  { deep: true, immediate: false }
843
1592
  );
844
1593
 
1594
+ /**
1595
+ * 监听 annotations 变化,重新渲染文本图层以显示批注标记
1596
+ */
1597
+ watch(
1598
+ () => props.annotations,
1599
+ () => {
1600
+ nextTick(() => {
1601
+ renderTextLayer();
1602
+ });
1603
+ },
1604
+ { deep: true }
1605
+ );
1606
+
1607
+ /**
1608
+ * 监听当前页码变化,重新渲染文本图层
1609
+ */
1610
+ watch(
1611
+ () => currentPage.value,
1612
+ () => {
1613
+ nextTick(() => {
1614
+ renderTextLayer();
1615
+ });
1616
+ }
1617
+ );
1618
+
1619
+ /**
1620
+ * 监听当前页码的blocksData变化,重新渲染文本图层
1621
+ */
1622
+ watch(
1623
+ () => currentPageBlocksData.value,
1624
+ () => {
1625
+ nextTick(() => {
1626
+ renderTextLayer();
1627
+ });
1628
+ },
1629
+ { deep: true }
1630
+ );
1631
+
845
1632
  /**
846
1633
  * 监听图片尺寸变化,重新渲染文本图层
847
1634
  */
@@ -869,9 +1656,16 @@ watch([scale, rotation, position], () => {
869
1656
  * 组件挂载时的初始化
870
1657
  */
871
1658
  onMounted(() => {
872
- // 如果图片已经加载完成,立即渲染文本图层
1659
+ // 如果图片已经加载完成,立即渲染文本图层和自适应宽度
873
1660
  if (imageRef.value && imageRef.value.complete) {
874
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
+ }
875
1669
  renderTextLayer();
876
1670
  });
877
1671
  }
@@ -885,11 +1679,18 @@ onBeforeUnmount(() => {
885
1679
  clearTimeout(hideTimer);
886
1680
  hideTimer = null;
887
1681
  }
1682
+ if (scrollPagingTimer) {
1683
+ clearTimeout(scrollPagingTimer);
1684
+ scrollPagingTimer = null;
1685
+ }
888
1686
  });
889
1687
 
890
1688
  defineExpose({
891
1689
  reset,
892
- jumpToPosition // 暴露定位方法给父组件
1690
+ jumpToPosition, // 暴露定位方法给父组件
1691
+ goToPage: switchToPage, // 暴露翻页方法给父组件
1692
+ getCurrentPage: () => currentPage.value, // 获取当前页码
1693
+ getTotalPages: () => totalPages.value // 获取总页数
893
1694
  });
894
1695
  </script>
895
1696
 
@@ -903,48 +1704,57 @@ defineExpose({
903
1704
  width: 100%;
904
1705
  height: 100%;
905
1706
  overflow: auto;
906
-
907
- // 自定义滚动条
908
- &::-webkit-scrollbar {
909
- width: 6px;
910
- }
911
-
912
- &::-webkit-scrollbar-thumb {
913
- background-color: #d1d5db;
914
- border-radius: 3px;
915
-
916
- &:hover {
917
- background-color: rgb(0 0 0 / 30%);
918
- }
919
- }
1707
+ }
920
1708
 
921
- &::-webkit-scrollbar-track {
922
- background-color: rgb(243 244 246);
923
- }
1709
+ // 页码信息样式
1710
+ .page-info {
1711
+ display: inline-flex;
1712
+ align-items: center;
1713
+ padding: 0 8px;
1714
+ font-size: 12px;
1715
+ color: #1d2129;
1716
+ white-space: nowrap;
924
1717
  }
925
1718
 
926
1719
  // 图片包装器容器(用于居中显示和提供滚动空间)
927
1720
  .image-wrapper-container {
928
1721
  display: flex;
929
- align-items: center;
1722
+ align-items: flex-start; // 改为顶部对齐,避免垂直居中产生间隙
930
1723
  justify-content: center;
931
1724
  width: 100%;
932
1725
  min-height: 100%;
933
- padding: 20px; // 添加内边距,确保放大后的图片有滚动空间
934
1726
  box-sizing: border-box;
935
1727
  // 确保容器能够正确滚动
936
1728
  position: relative;
1729
+ // 移除可能的默认间隙
1730
+ line-height: 0;
1731
+ font-size: 0;
1732
+ // 移除可能的内边距和外边距
1733
+ margin: 0;
1734
+ padding: 0;
937
1735
  }
938
1736
 
939
1737
  // 图片包装器(包含图片和文本图层)
940
1738
  .image-wrapper {
941
1739
  position: relative;
942
- display: inline-block;
943
- transform-origin: center center;
1740
+ display: block; // 改用 block 避免 inline-block 的间隙问题
1741
+ transform-origin: top center; // 从顶部中心缩放,避免顶部产生间隙
944
1742
  flex-shrink: 0;
945
1743
  // 确保包装器能够正确占据空间,支持滚动
946
1744
  max-width: none;
947
1745
  max-height: none;
1746
+ // 移除可能的默认间隙
1747
+ margin: 0;
1748
+ padding: 0;
1749
+ line-height: 0;
1750
+
1751
+ img {
1752
+ display: block; // 移除图片底部默认间隙
1753
+ margin: 0;
1754
+ padding: 0;
1755
+ position: relative; // 确保图片在文本下方
1756
+ z-index: 1; // 图片的 z-index 低于文本
1757
+ }
948
1758
  }
949
1759
 
950
1760
  // 文本图层样式
@@ -955,10 +1765,38 @@ defineExpose({
955
1765
  width: 100%;
956
1766
  height: 100%;
957
1767
  pointer-events: auto; // 允许鼠标事件
1768
+ z-index: 20; // 确保文本图层在图片上方
958
1769
  }
959
1770
 
960
- // 复制按钮浮层
961
- .copy-button-popup {
1771
+ // 文本块样式
1772
+ .text-block {
1773
+ // 确保文本可以选中
1774
+ user-select: text !important;
1775
+ -webkit-user-select: text !important;
1776
+ -moz-user-select: text !important;
1777
+ -ms-user-select: text !important;
1778
+ // 红色文字,确保可见
1779
+ color: red !important;
1780
+ // 设置明确的字体大小和样式
1781
+ font-size: 14px !important;
1782
+ font-family: Arial, sans-serif !important;
1783
+ line-height: 1.2 !important;
1784
+ // 确保文本可以复制
1785
+ cursor: text !important;
1786
+ // 确保文本可以交互
1787
+ pointer-events: auto !important;
1788
+ // 确保文本可以选中和复制
1789
+ -webkit-touch-callout: default;
1790
+ -webkit-tap-highlight-color: transparent;
1791
+ // 确保文字不被裁剪
1792
+ overflow: visible !important;
1793
+ // 确保可见
1794
+ visibility: visible !important;
1795
+ display: block !important;
1796
+ }
1797
+
1798
+ // 批注按钮浮层
1799
+ .annotation-button-popup {
962
1800
  position: absolute;
963
1801
  z-index: 1000;
964
1802
  display: flex;
@@ -971,7 +1809,7 @@ defineExpose({
971
1809
  box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
972
1810
  animation: fade-in 0.2s ease;
973
1811
 
974
- .copy-button-popup-action {
1812
+ .annotation-button-action {
975
1813
  display: flex;
976
1814
  flex-direction: row;
977
1815
  align-items: center;
@@ -984,11 +1822,115 @@ defineExpose({
984
1822
  border-radius: 4px;
985
1823
  }
986
1824
 
987
- .copy-button-popup-action:hover {
1825
+ .annotation-button-action:hover {
988
1826
  background-color: #f0f0f0;
989
1827
  }
990
1828
  }
991
1829
 
1830
+ // 批注输入弹窗
1831
+ .annotation-input-popup {
1832
+ position: absolute;
1833
+ z-index: 1001;
1834
+ width: 320px;
1835
+ pointer-events: auto;
1836
+ background-color: #fff;
1837
+ border-radius: 8px;
1838
+ box-shadow: 0 4px 16px rgb(0 0 0 / 20%);
1839
+ animation: fade-in 0.2s ease;
1840
+ overflow: hidden;
1841
+
1842
+ .annotation-input-header {
1843
+ display: flex;
1844
+ align-items: center;
1845
+ justify-content: space-between;
1846
+ padding: 12px 16px;
1847
+ border-bottom: 1px solid #e5e7eb;
1848
+
1849
+ .annotation-input-title {
1850
+ font-size: 14px;
1851
+ font-weight: 500;
1852
+ color: #1d2129;
1853
+ }
1854
+
1855
+ .annotation-close-btn {
1856
+ display: flex;
1857
+ align-items: center;
1858
+ justify-content: center;
1859
+ width: 24px;
1860
+ height: 24px;
1861
+ padding: 0;
1862
+ border: none;
1863
+ background: transparent;
1864
+ cursor: pointer;
1865
+ border-radius: 4px;
1866
+ color: #86909c;
1867
+ transition: all 0.2s;
1868
+
1869
+ &:hover {
1870
+ background-color: #f0f0f0;
1871
+ color: #1d2129;
1872
+ }
1873
+ }
1874
+ }
1875
+
1876
+ .annotation-input-content {
1877
+ padding: 12px 16px;
1878
+
1879
+ .annotation-textarea {
1880
+ width: 100%;
1881
+ }
1882
+ }
1883
+
1884
+ .annotation-input-footer {
1885
+ display: flex;
1886
+ align-items: center;
1887
+ justify-content: flex-end;
1888
+ gap: 8px;
1889
+ padding: 12px 16px;
1890
+ border-top: 1px solid #e5e7eb;
1891
+ }
1892
+ }
1893
+
1894
+ // 有批注的文本块样式
1895
+ .text-block.has-annotation {
1896
+ position: relative;
1897
+ // 添加背景色区分
1898
+ background-color: rgba(255, 243, 205, 0.5) !important; // 浅黄色背景
1899
+ // 添加边框
1900
+ border: 1px solid rgba(255, 193, 7, 0.7) !important; // 黄色边框
1901
+ border-radius: 3px;
1902
+ // 添加内边距,让文字不贴边
1903
+ padding: 1px 3px;
1904
+ // 添加阴影效果
1905
+ box-shadow: 0 1px 2px rgba(255, 193, 7, 0.25) !important;
1906
+
1907
+ // 鼠标悬停时加深效果
1908
+ &:hover {
1909
+ background-color: rgba(255, 243, 205, 0.7) !important;
1910
+ border-color: rgba(255, 193, 7, 0.9) !important;
1911
+ box-shadow: 0 2px 4px rgba(255, 193, 7, 0.35) !important;
1912
+ }
1913
+
1914
+ // 使用伪元素添加批注标记图标
1915
+ &::before {
1916
+ content: '📝';
1917
+ position: absolute;
1918
+ top: -6px;
1919
+ right: -6px;
1920
+ font-size: 11px;
1921
+ background-color: rgba(255, 193, 7, 0.95);
1922
+ border-radius: 50%;
1923
+ width: 16px;
1924
+ height: 16px;
1925
+ display: flex;
1926
+ align-items: center;
1927
+ justify-content: center;
1928
+ z-index: 30;
1929
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
1930
+ line-height: 1;
1931
+ }
1932
+ }
1933
+
992
1934
  // 淡入动画
993
1935
  @keyframes fade-in {
994
1936
  from {