@koi-br/ocr-web-sdk 1.0.43 → 1.0.44

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.
@@ -116,13 +116,24 @@
116
116
  @mouseleave="stopPan"
117
117
  @scroll="handleScroll"
118
118
  >
119
- <div class="image-wrapper-container" :style="containerStyle">
119
+ <!-- 自适应宽度计算 Loading -->
120
+ <div v-if="isCalculatingAutoFit && autoFitWidth" class="auto-fit-loading">
121
+ <div class="loading-spinner"></div>
122
+ <div class="loading-text">加载中...</div>
123
+ </div>
124
+
125
+ <div
126
+ class="image-wrapper-container"
127
+ :style="containerStyle"
128
+ :class="{ 'image-hidden': !isImageReady && autoFitWidth }"
129
+ >
120
130
  <!-- 渲染所有图片页面 -->
121
131
  <div
122
132
  v-for="(imageUrl, pageIndex) in imageUrls"
123
133
  :key="pageIndex"
124
134
  :data-page-number="pageIndex + 1"
125
135
  class="image-page-container"
136
+ :style="getPageContainerStyle(pageIndex + 1)"
126
137
  >
127
138
  <div
128
139
  class="image-wrapper"
@@ -389,12 +400,48 @@ const position = ref({ x: 0, y: 0 });
389
400
  const isPanning = ref(false);
390
401
  const lastPosition = ref({ x: 0, y: 0 });
391
402
  const initialAutoFitScale = ref<number | null>(null); // 记录初始自适应缩放比例
403
+ const isCalculatingAutoFit = ref(false); // 标记是否正在计算自适应宽度(显示 loading)
404
+ const isImageReady = ref(false); // 标记图片是否已准备好显示(自适应宽度计算完成)
405
+
406
+ // 监听图片URL变化,当有新图片时立即隐藏(等待自适应宽度计算)
407
+ watch(
408
+ () => imageUrls.value,
409
+ (newUrls, oldUrls) => {
410
+ console.log('[ImagePreview] imageUrls changed:', {
411
+ newUrls: newUrls?.length,
412
+ oldUrls: oldUrls?.length,
413
+ autoFitWidth: props.autoFitWidth,
414
+ isImageReady: isImageReady.value,
415
+ isCalculatingAutoFit: isCalculatingAutoFit.value,
416
+ });
417
+
418
+ // 如果有新的图片URL,且启用自适应宽度,立即隐藏图片
419
+ if (newUrls && newUrls.length > 0 && props.autoFitWidth) {
420
+ console.log('[ImagePreview] 设置图片隐藏,等待自适应宽度计算');
421
+ isImageReady.value = false;
422
+ isCalculatingAutoFit.value = true;
423
+ } else if (!props.autoFitWidth) {
424
+ // 如果没有启用自适应宽度,立即显示
425
+ console.log('[ImagePreview] 未启用自适应宽度,立即显示图片');
426
+ isImageReady.value = true;
427
+ isCalculatingAutoFit.value = false;
428
+ }
429
+ },
430
+ { immediate: true }
431
+ );
392
432
  const isUserZooming = ref(false); // 标记用户是否主动缩放
393
433
 
394
434
  // 滚动翻页相关
395
435
  let scrollPagingTimer: any = null;
396
436
  const isScrollPaging = ref(false); // 标记是否正在进行滚动翻页
397
437
 
438
+ // ResizeObserver 相关
439
+ let resizeObserver: ResizeObserver | null = null;
440
+ let resizeTimer: any = null; // handleContainerResize 内部的定时器
441
+ let resizeDebounceTimer: any = null; // ResizeObserver 的防抖定时器
442
+ let isResizing = false; // 标记是否正在处理 resize
443
+ let lastContainerWidth = 0; // 记录上次容器宽度,用于判断是否真的变化了
444
+
398
445
  // 图片和容器引用
399
446
  const containerRef = ref<HTMLElement>();
400
447
  const imageRefs = new Map<number, HTMLImageElement>();
@@ -514,6 +561,35 @@ const getPageBlocksData = (pageNo: number) => {
514
561
  return props.blocksData.filter((block) => block.pageNo === pageNo);
515
562
  };
516
563
 
564
+ // 计算指定页面缩放后的尺寸(考虑旋转)
565
+ const getPageScaledSize = (pageNo: number) => {
566
+ const pageSize = imageSizes.get(pageNo);
567
+ if (!pageSize || pageSize.width === 0 || pageSize.height === 0) {
568
+ return { width: 0, height: 0 };
569
+ }
570
+
571
+ const { width, height } = pageSize;
572
+ const scaledWidth = width * scale.value;
573
+ const scaledHeight = height * scale.value;
574
+
575
+ // 如果旋转了 90 度或 270 度,交换宽高
576
+ const normalizedRotation = ((rotation.value % 360) + 360) % 360;
577
+ const isRotated = normalizedRotation === 90 || normalizedRotation === 270;
578
+
579
+ return {
580
+ width: isRotated ? scaledHeight : scaledWidth,
581
+ height: isRotated ? scaledWidth : scaledHeight,
582
+ };
583
+ };
584
+
585
+ // 获取页面容器的样式(响应式,会根据缩放和旋转自动更新)
586
+ const getPageContainerStyle = (pageNo: number) => {
587
+ const scaledSize = getPageScaledSize(pageNo);
588
+ return {
589
+ height: scaledSize.height > 0 ? `${scaledSize.height}px` : 'auto',
590
+ };
591
+ };
592
+
517
593
  // 隐藏定时器
518
594
  let hideTimer: any = null;
519
595
 
@@ -807,10 +883,18 @@ const switchToPage = (page: number) => {
807
883
  `[data-page-number="${page}"]`
808
884
  ) as HTMLElement;
809
885
  if (pageElement) {
886
+ // 标记这是翻页滚动,不应该被同步滚动干扰
887
+ containerRef.value.dataset.pageScrolling = 'true';
810
888
  pageElement.scrollIntoView({ behavior: "smooth", block: "start" });
811
889
  // 更新 lastScrollTop,确保滚动方向判断准确
812
890
  nextTick(() => {
813
891
  lastScrollTop = containerRef.value?.scrollTop || 0;
892
+ // 延迟清除标记,确保滚动完成
893
+ setTimeout(() => {
894
+ if (containerRef.value) {
895
+ delete containerRef.value.dataset.pageScrolling;
896
+ }
897
+ }, 500); // scrollIntoView 的 smooth 动画通常需要 300-500ms
814
898
  });
815
899
  }
816
900
  }
@@ -846,13 +930,25 @@ const original = () => {
846
930
 
847
931
  // 计算自适应宽度的缩放比例
848
932
  const calculateAutoFitScale = () => {
933
+ console.log('[ImagePreview] calculateAutoFitScale 开始:', {
934
+ autoFitWidth: props.autoFitWidth,
935
+ hasContainerRef: !!containerRef.value,
936
+ containerRect: containerRef.value?.getBoundingClientRect(),
937
+ firstPageSize: imageSizes.get(1),
938
+ rotation: rotation.value,
939
+ minScale: props.minScale,
940
+ maxScale: props.maxScale,
941
+ });
942
+
849
943
  if (!props.autoFitWidth || !containerRef.value) {
944
+ console.log('[ImagePreview] calculateAutoFitScale 返回 1 (条件不满足)');
850
945
  return 1;
851
946
  }
852
947
 
853
948
  // 使用第一页的图片尺寸作为基准(所有页面使用相同的缩放比例)
854
949
  const firstPageSize = imageSizes.get(1);
855
950
  if (!firstPageSize || firstPageSize.width === 0) {
951
+ console.log('[ImagePreview] calculateAutoFitScale 返回 1 (第一页尺寸无效)', firstPageSize);
856
952
  return 1;
857
953
  }
858
954
 
@@ -862,6 +958,7 @@ const calculateAutoFitScale = () => {
862
958
  const containerWidth = containerRect.width - 4;
863
959
 
864
960
  if (containerWidth <= 0) {
961
+ console.log('[ImagePreview] calculateAutoFitScale 返回 1 (容器宽度无效)', containerWidth);
865
962
  return 1;
866
963
  }
867
964
 
@@ -872,20 +969,38 @@ const calculateAutoFitScale = () => {
872
969
  const imageWidth = isRotated ? firstPageSize.height : firstPageSize.width;
873
970
 
874
971
  if (imageWidth <= 0) {
972
+ console.log('[ImagePreview] calculateAutoFitScale 返回 1 (图片宽度无效)', imageWidth);
875
973
  return 1;
876
974
  }
877
975
 
878
976
  // 计算缩放比例,使图片宽度完全适应容器宽度
879
977
  const calculatedScale = containerWidth / imageWidth;
978
+ const finalScale = Math.max(props.minScale, Math.min(props.maxScale, calculatedScale));
979
+
980
+ console.log('[ImagePreview] calculateAutoFitScale 计算结果:', {
981
+ containerWidth,
982
+ imageWidth,
983
+ calculatedScale,
984
+ finalScale,
985
+ });
880
986
 
881
987
  // 确保缩放比例在允许的范围内
882
- return Math.max(props.minScale, Math.min(props.maxScale, calculatedScale));
988
+ return finalScale;
883
989
  };
884
990
 
885
991
  // 图片加载完成处理
886
992
  const onImageLoad = (event: Event, pageNum: number) => {
887
993
  const img = event.target as HTMLImageElement;
888
994
 
995
+ console.log('[ImagePreview] 图片加载完成:', {
996
+ pageNum,
997
+ naturalWidth: img.naturalWidth,
998
+ naturalHeight: img.naturalHeight,
999
+ autoFitWidth: props.autoFitWidth,
1000
+ isImageReady: isImageReady.value,
1001
+ isCalculatingAutoFit: isCalculatingAutoFit.value,
1002
+ });
1003
+
889
1004
  // 存储该页的图片尺寸
890
1005
  imageSizes.set(pageNum, {
891
1006
  width: img.naturalWidth,
@@ -894,22 +1009,69 @@ const onImageLoad = (event: Event, pageNum: number) => {
894
1009
 
895
1010
  // 如果是第一页且启用自适应宽度,计算并设置初始缩放比例
896
1011
  if (pageNum === 1 && props.autoFitWidth) {
1012
+ console.log('[ImagePreview] 第一页加载完成,开始计算自适应宽度');
897
1013
  // 重置用户缩放标记
898
1014
  isUserZooming.value = false;
1015
+
1016
+ // 确保图片是隐藏的(watch 已经设置了,这里再次确认)
1017
+ if (!isImageReady.value) {
1018
+ isCalculatingAutoFit.value = true;
1019
+ }
1020
+
1021
+ // 设置超时保护,防止一直显示 loading(最多等待 3 秒)
1022
+ const timeoutId = setTimeout(() => {
1023
+ console.warn('自适应宽度计算超时,强制显示图片');
1024
+ isCalculatingAutoFit.value = false;
1025
+ isImageReady.value = true;
1026
+ }, 3000);
899
1027
 
900
1028
  // 使用双重 nextTick 确保容器尺寸已确定
901
1029
  nextTick(() => {
902
1030
  nextTick(() => {
903
1031
  // 添加小延迟确保容器完全渲染
904
1032
  setTimeout(() => {
905
- const autoScale = calculateAutoFitScale();
906
- if (autoScale !== 1 && autoScale > 0) {
907
- scale.value = autoScale;
908
- initialAutoFitScale.value = autoScale; // 记录初始自适应缩放比例
1033
+ try {
1034
+ console.log('[ImagePreview] onImageLoad: 开始计算自适应宽度...');
1035
+ const autoScale = calculateAutoFitScale();
1036
+ console.log('[ImagePreview] onImageLoad: 自适应宽度计算结果:', {
1037
+ autoScale,
1038
+ containerRef: !!containerRef.value,
1039
+ containerWidth: containerRef.value?.getBoundingClientRect()?.width,
1040
+ firstPageSize: imageSizes.get(1),
1041
+ });
1042
+
1043
+ if (autoScale > 0) {
1044
+ scale.value = autoScale;
1045
+ initialAutoFitScale.value = autoScale; // 记录初始自适应缩放比例
1046
+ // 记录当前容器宽度,用于后续 resize 检查
1047
+ if (containerRef.value) {
1048
+ lastContainerWidth = containerRef.value.getBoundingClientRect().width;
1049
+ }
1050
+ console.log('[ImagePreview] onImageLoad: 缩放比例已设置:', autoScale);
1051
+ } else {
1052
+ console.warn('[ImagePreview] onImageLoad: 计算出的缩放比例无效:', autoScale);
1053
+ }
1054
+ } catch (error) {
1055
+ console.error('[ImagePreview] onImageLoad: 计算自适应宽度失败:', error);
1056
+ } finally {
1057
+ // 清除超时保护
1058
+ clearTimeout(timeoutId);
1059
+ console.log('[ImagePreview] onImageLoad: 更新状态: isCalculatingAutoFit = false, isImageReady = true');
1060
+ // 无论计算结果如何,都要更新状态,避免一直显示 loading
1061
+ isCalculatingAutoFit.value = false;
1062
+ // 使用 requestAnimationFrame 确保在下一帧显示,避免闪烁
1063
+ requestAnimationFrame(() => {
1064
+ isImageReady.value = true;
1065
+ console.log('[ImagePreview] onImageLoad: 图片已准备好显示');
1066
+ });
909
1067
  }
910
1068
  }, 100); // 增加延迟,确保所有图片都已加载
911
1069
  });
912
1070
  });
1071
+ } else if (!props.autoFitWidth) {
1072
+ // 如果没有启用自适应宽度,立即显示图片
1073
+ isImageReady.value = true;
1074
+ isCalculatingAutoFit.value = false;
913
1075
  }
914
1076
 
915
1077
  // 如果第一页已经加载完成,且当前页不是第一页,也应用自适应宽度
@@ -1121,9 +1283,13 @@ const renderTextLayer = (pageNum?: number) => {
1121
1283
  hideTimer = null;
1122
1284
  }
1123
1285
 
1124
- // 如果有之前激活的文本块,先恢复其样式
1125
- if (activeBlockDiv.value && activeBlockDiv.value !== blockDiv) {
1126
- restoreBlockStyle(activeBlockDiv.value);
1286
+ // 清除当前页面所有文本块的高亮样式(除了当前文本块)
1287
+ // 这样可以防止多个文本块同时高亮的竞态条件
1288
+ clearAllHighlights(blockDiv);
1289
+
1290
+ // 清除跳转高亮标志(如果之前是通过跳转高亮的)
1291
+ if (isHighlighted.value) {
1292
+ isHighlighted.value = false;
1127
1293
  }
1128
1294
 
1129
1295
  // 设置当前文本块为激活状态
@@ -1139,7 +1305,10 @@ const renderTextLayer = (pageNum?: number) => {
1139
1305
  blockDiv.style.setProperty("padding", "1px 3px", "important");
1140
1306
  blockDiv.style.setProperty("box-shadow", "0 0 0 2px rgba(30, 144, 255, 0.6), 0 1px 2px rgba(255, 193, 7, 0.25)", "important");
1141
1307
  } else {
1142
- // 如果没有批注,使用 hover 样式(与 PdfPreview 保持一致)
1308
+ // 如果没有批注,先清除可能残留的样式,再设置 hover 样式(与 PdfPreview 保持一致)
1309
+ blockDiv.style.removeProperty("background-color");
1310
+ blockDiv.style.removeProperty("border");
1311
+ blockDiv.style.removeProperty("box-shadow");
1143
1312
  blockDiv.style.backgroundColor =
1144
1313
  "var(--s-color-brand-primary-transparent-3, rgba(0, 102, 255, .15))";
1145
1314
  blockDiv.style.boxShadow = "0 0 0 2px rgba(30, 144, 255, 0.6)";
@@ -1303,6 +1472,75 @@ const showAnnotationButtonForBlock = (
1303
1472
  }
1304
1473
  };
1305
1474
 
1475
+ /**
1476
+ * 清除指定页面所有文本块的高亮样式(除了指定的文本块)
1477
+ * @param excludeBlockDiv 要排除的文本块(不清除它的样式)
1478
+ * @param pageNum 要清除的页码,如果不提供则从 excludeBlockDiv 中获取
1479
+ */
1480
+ const clearAllHighlights = (excludeBlockDiv?: HTMLElement, pageNum?: number) => {
1481
+ // 确定要清除的页码
1482
+ let targetPage = pageNum;
1483
+ if (!targetPage && excludeBlockDiv) {
1484
+ const pageStr = excludeBlockDiv.dataset.page;
1485
+ targetPage = pageStr ? parseInt(pageStr, 10) : currentPage.value;
1486
+ }
1487
+ if (!targetPage) {
1488
+ targetPage = currentPage.value;
1489
+ }
1490
+
1491
+ const textLayer = textLayerRefs.get(targetPage);
1492
+ if (!textLayer) return;
1493
+
1494
+ const blockDivs = textLayer.querySelectorAll(".text-block");
1495
+ blockDivs.forEach((div) => {
1496
+ const el = div as HTMLElement;
1497
+ // 如果是指定的文本块,跳过
1498
+ if (excludeBlockDiv && el === excludeBlockDiv) {
1499
+ return;
1500
+ }
1501
+
1502
+ // 检查是否有批注
1503
+ const bboxStr = el.dataset.bbox;
1504
+ if (!bboxStr) return;
1505
+
1506
+ try {
1507
+ const bbox = JSON.parse(bboxStr) as [number, number, number, number];
1508
+ const pageStr = el.dataset.page;
1509
+ const pageNum = pageStr ? parseInt(pageStr, 10) : undefined;
1510
+ const existingAnnotation = getAnnotationForBlock(bbox, pageNum);
1511
+
1512
+ if (existingAnnotation) {
1513
+ // 如果有批注,恢复批注样式(不使用 hover 样式)
1514
+ el.style.setProperty("background-color", "rgba(255, 243, 205, 0.5)", "important");
1515
+ el.style.setProperty("border", "1px solid rgba(255, 193, 7, 0.7)", "important");
1516
+ el.style.setProperty("border-radius", "3px", "important");
1517
+ el.style.setProperty("padding", "1px 3px", "important");
1518
+ el.style.setProperty("box-shadow", "0 1px 2px rgba(255, 193, 7, 0.25)", "important");
1519
+ } else {
1520
+ // 如果没有批注,清除所有高亮样式
1521
+ el.style.backgroundColor = "transparent";
1522
+ el.style.border = "none";
1523
+ el.style.borderRadius = "2px";
1524
+ el.style.padding = "0";
1525
+ el.style.boxShadow = "none";
1526
+ el.style.removeProperty("background-color");
1527
+ el.style.removeProperty("border");
1528
+ el.style.removeProperty("box-shadow");
1529
+ }
1530
+ } catch (error) {
1531
+ // 如果解析失败,清除所有高亮样式
1532
+ el.style.backgroundColor = "transparent";
1533
+ el.style.border = "none";
1534
+ el.style.borderRadius = "2px";
1535
+ el.style.padding = "0";
1536
+ el.style.boxShadow = "none";
1537
+ el.style.removeProperty("background-color");
1538
+ el.style.removeProperty("border");
1539
+ el.style.removeProperty("box-shadow");
1540
+ }
1541
+ });
1542
+ };
1543
+
1306
1544
  /**
1307
1545
  * 恢复文本块样式(根据是否有批注)
1308
1546
  */
@@ -1358,19 +1596,29 @@ const restoreBlockStyle = (blockDiv: HTMLElement) => {
1358
1596
  computedAfter: window.getComputedStyle(blockDiv).backgroundColor,
1359
1597
  });
1360
1598
  } else {
1361
- // 如果没有批注,恢复透明背景
1599
+ // 如果没有批注,恢复透明背景(清除所有高亮相关样式)
1362
1600
  blockDiv.style.backgroundColor = "transparent";
1363
1601
  blockDiv.style.border = "none";
1602
+ blockDiv.style.borderRadius = "2px"; // 恢复默认值
1364
1603
  blockDiv.style.padding = "0";
1365
1604
  blockDiv.style.boxShadow = "none";
1605
+ // 清除可能残留的样式属性
1606
+ blockDiv.style.removeProperty("background-color");
1607
+ blockDiv.style.removeProperty("border");
1608
+ blockDiv.style.removeProperty("box-shadow");
1366
1609
  }
1367
1610
  } catch (error) {
1368
1611
  console.error("restoreBlockStyle 错误:", error);
1369
- // 如果解析失败,恢复透明背景
1612
+ // 如果解析失败,恢复透明背景(清除所有高亮相关样式)
1370
1613
  blockDiv.style.backgroundColor = "transparent";
1371
1614
  blockDiv.style.border = "none";
1615
+ blockDiv.style.borderRadius = "2px"; // 恢复默认值
1372
1616
  blockDiv.style.padding = "0";
1373
1617
  blockDiv.style.boxShadow = "none";
1618
+ // 清除可能残留的样式属性
1619
+ blockDiv.style.removeProperty("background-color");
1620
+ blockDiv.style.removeProperty("border");
1621
+ blockDiv.style.removeProperty("box-shadow");
1374
1622
  }
1375
1623
  };
1376
1624
 
@@ -1408,19 +1656,32 @@ const hideAnnotationButton = () => {
1408
1656
  activeBlockDiv.value.style.boxShadow =
1409
1657
  "0 1px 2px rgba(255, 193, 7, 0.25)";
1410
1658
  } else {
1411
- // 如果没有批注,恢复透明背景
1659
+ // 如果没有批注,恢复透明背景(清除所有高亮相关样式)
1412
1660
  activeBlockDiv.value.style.backgroundColor = "transparent";
1413
1661
  activeBlockDiv.value.style.border = "none";
1662
+ activeBlockDiv.value.style.borderRadius = "2px"; // 恢复默认值
1414
1663
  activeBlockDiv.value.style.padding = "0";
1415
1664
  activeBlockDiv.value.style.boxShadow = "none";
1665
+ // 清除可能残留的样式属性
1666
+ activeBlockDiv.value.style.removeProperty("background-color");
1667
+ activeBlockDiv.value.style.removeProperty("border");
1668
+ activeBlockDiv.value.style.removeProperty("box-shadow");
1416
1669
  }
1417
1670
  } catch (error) {
1671
+ // 如果解析失败,恢复透明背景(清除所有高亮相关样式)
1418
1672
  activeBlockDiv.value.style.backgroundColor = "transparent";
1419
1673
  activeBlockDiv.value.style.border = "none";
1674
+ activeBlockDiv.value.style.borderRadius = "2px"; // 恢复默认值
1420
1675
  activeBlockDiv.value.style.padding = "0";
1421
1676
  activeBlockDiv.value.style.boxShadow = "none";
1677
+ // 清除可能残留的样式属性
1678
+ activeBlockDiv.value.style.removeProperty("background-color");
1679
+ activeBlockDiv.value.style.removeProperty("border");
1680
+ activeBlockDiv.value.style.removeProperty("box-shadow");
1422
1681
  }
1423
1682
  }
1683
+ // 清除高亮标志
1684
+ isHighlighted.value = false;
1424
1685
  activeBlockDiv.value = null;
1425
1686
  }
1426
1687
  }, 300);
@@ -1707,6 +1968,33 @@ const saveAnnotation = () => {
1707
1968
  * 处理滚动事件(隐藏批注按钮和文本块高亮,以及滚动翻页)
1708
1969
  */
1709
1970
  const handleScroll = (e: Event) => {
1971
+ const container = e.target as HTMLElement;
1972
+
1973
+ // 检查是否是同步滚动触发的
1974
+ const isSyncing = container?.dataset?.syncingScroll === 'true';
1975
+ if (isSyncing) {
1976
+ // 即使是被同步滚动触发的,也应该立即更新页码(但不触发翻页动画)
1977
+ // 使用 requestAnimationFrame 确保在浏览器渲染后立即更新
1978
+ if (
1979
+ props.enableScrollPaging &&
1980
+ totalPages.value > 1 &&
1981
+ !isScrollPaging.value
1982
+ ) {
1983
+ // 立即更新页码,不等待防抖
1984
+ requestAnimationFrame(() => {
1985
+ updateCurrentPageFromScroll();
1986
+ // 清除标记(在页码更新后)
1987
+ delete container.dataset.syncingScroll;
1988
+ });
1989
+ } else {
1990
+ // 如果没有启用滚动翻页,立即清除标记
1991
+ delete container.dataset.syncingScroll;
1992
+ }
1993
+
1994
+ // 同步滚动时,不执行其他逻辑(如隐藏批注按钮等)
1995
+ return;
1996
+ }
1997
+
1710
1998
  if (hideTimer) {
1711
1999
  clearTimeout(hideTimer);
1712
2000
  }
@@ -1728,23 +2016,74 @@ const handleScroll = (e: Event) => {
1728
2016
  scale.value = initialAutoFitScale.value;
1729
2017
  }
1730
2018
 
1731
- // 滚动翻页功能
2019
+ // 滚动翻页功能(用户主动滚动时)
1732
2020
  if (
1733
2021
  props.enableScrollPaging &&
1734
2022
  totalPages.value > 1 &&
1735
2023
  !isScrollPaging.value
1736
2024
  ) {
2025
+ // 立即更新页码(使用 requestAnimationFrame 确保在渲染后更新)
2026
+ requestAnimationFrame(() => {
2027
+ updateCurrentPageFromScroll();
2028
+ });
2029
+ // 同时使用防抖处理其他逻辑(如事件触发)
1737
2030
  handleScrollPaging(e);
1738
2031
  }
1739
2032
  };
1740
2033
 
2034
+
1741
2035
  // 记录上次滚动位置,用于判断滚动方向
1742
2036
  let lastScrollTop = 0;
1743
2037
 
2038
+ /**
2039
+ * 根据滚动位置更新当前页码
2040
+ * 通过找到距离视口中心最近的页面来确定当前页
2041
+ * 使用 getBoundingClientRect() 获取实际渲染位置(考虑了 transform: scale)
2042
+ */
2043
+ const updateCurrentPageFromScroll = () => {
2044
+ if (!containerRef.value) return;
2045
+
2046
+ const container = containerRef.value;
2047
+ const containerRect = container.getBoundingClientRect();
2048
+ const containerTop = containerRect.top;
2049
+ const containerHeight = container.clientHeight;
2050
+ const containerCenter = containerTop + containerHeight / 2;
2051
+
2052
+ // 遍历所有页面,找到距离视口中心最近的页面
2053
+ let closestPage = 1;
2054
+ let closestDistance = Infinity;
2055
+
2056
+ for (let pageNum = 1; pageNum <= totalPages.value; pageNum++) {
2057
+ const pageElement = container.querySelector(
2058
+ `[data-page-number="${pageNum}"]`
2059
+ ) as HTMLElement;
2060
+
2061
+ if (pageElement) {
2062
+ // 使用 getBoundingClientRect() 获取实际渲染位置(考虑了 transform: scale)
2063
+ // 而不是使用 offsetTop(不考虑 transform)
2064
+ const pageRect = pageElement.getBoundingClientRect();
2065
+ const pageTop = pageRect.top;
2066
+ const pageHeight = pageRect.height;
2067
+ const pageCenter = pageTop + pageHeight / 2;
2068
+ const distance = Math.abs(pageCenter - containerCenter);
2069
+
2070
+ if (distance < closestDistance) {
2071
+ closestDistance = distance;
2072
+ closestPage = pageNum;
2073
+ }
2074
+ }
2075
+ }
2076
+
2077
+ // 只有当页码真正改变时才更新
2078
+ if (closestPage !== currentPage.value) {
2079
+ currentPage.value = closestPage;
2080
+ emit("page-change", closestPage, totalPages.value);
2081
+ }
2082
+ };
2083
+
1744
2084
  /**
1745
2085
  * 处理滚动翻页(通过滚动位置判断当前页)
1746
- * 向下滑动:当视口顶部到达下一页的顶部时,切换到下一页
1747
- * 向上滑动:当视口底部到达上一页的底部时,切换到上一页
2086
+ * 使用视口中心最近的页面来确定当前页,更准确
1748
2087
  */
1749
2088
  const handleScrollPaging = (e: Event) => {
1750
2089
  const container = e.target as HTMLElement;
@@ -1761,80 +2100,16 @@ const handleScrollPaging = (e: Event) => {
1761
2100
  }
1762
2101
 
1763
2102
  // 使用防抖,避免频繁触发
2103
+ // 减少延迟到50ms,提高响应速度
1764
2104
  scrollPagingTimer = setTimeout(() => {
1765
2105
  // 再次检查是否正在翻页
1766
2106
  if (isScrollPaging.value) {
1767
2107
  return;
1768
2108
  }
1769
2109
 
1770
- const scrollTop = container.scrollTop;
1771
- const clientHeight = container.clientHeight;
1772
- const scrollBottom = scrollTop + clientHeight;
1773
-
1774
- // 判断滚动方向
1775
- const isScrollingDown = scrollTop > lastScrollTop;
1776
- lastScrollTop = scrollTop;
1777
-
1778
- // 获取当前页的元素
1779
- const currentPageElement = container.querySelector(
1780
- `[data-page-number="${currentPage.value}"]`
1781
- ) as HTMLElement;
1782
-
1783
- if (!currentPageElement) return;
1784
-
1785
- const currentPageTop = currentPageElement.offsetTop;
1786
- const currentPageHeight = currentPageElement.offsetHeight;
1787
- const currentPageBottom = currentPageTop + currentPageHeight;
1788
-
1789
- let newPage = currentPage.value;
1790
-
1791
- if (isScrollingDown) {
1792
- // 向下滑动:当视口顶部到达或超过下一页的顶部时,切换到下一页
1793
- if (currentPage.value < totalPages.value) {
1794
- const nextPageElement = container.querySelector(
1795
- `[data-page-number="${currentPage.value + 1}"]`
1796
- ) as HTMLElement;
1797
-
1798
- if (nextPageElement) {
1799
- const nextPageTop = nextPageElement.offsetTop;
1800
- // 当视口顶部到达或超过下一页的顶部时,切换到下一页
1801
- if (scrollTop >= nextPageTop) {
1802
- newPage = currentPage.value + 1;
1803
- }
1804
- }
1805
- }
1806
- } else {
1807
- // 向上滑动:当视口底部到达或超过上一页的底部时,切换到上一页
1808
- if (currentPage.value > 1) {
1809
- const prevPageElement = container.querySelector(
1810
- `[data-page-number="${currentPage.value - 1}"]`
1811
- ) as HTMLElement;
1812
-
1813
- if (prevPageElement) {
1814
- const prevPageTop = prevPageElement.offsetTop;
1815
- const prevPageHeight = prevPageElement.offsetHeight;
1816
- const prevPageBottom = prevPageTop + prevPageHeight;
1817
-
1818
- // 当视口底部到达或超过上一页的底部时,切换到上一页
1819
- if (scrollBottom <= prevPageBottom) {
1820
- newPage = currentPage.value - 1;
1821
- }
1822
- }
1823
- }
1824
- }
1825
-
1826
- // 如果页码发生变化,更新当前页
1827
- if (newPage !== currentPage.value) {
1828
- isScrollPaging.value = true;
1829
- currentPage.value = newPage;
1830
- emit("page-change", newPage, totalPages.value);
1831
-
1832
- // 延迟解锁,确保页面切换完成
1833
- setTimeout(() => {
1834
- isScrollPaging.value = false;
1835
- }, 100);
1836
- }
1837
- }, 100);
2110
+ // 更新当前页码(基于视口中心最近的页面)
2111
+ updateCurrentPageFromScroll();
2112
+ }, 50);
1838
2113
  };
1839
2114
 
1840
2115
  /**
@@ -1962,7 +2237,15 @@ const highlightPosition = (
1962
2237
  if (shouldScroll && containerRef.value) {
1963
2238
  const isVisible = isElementVisible(elementRef, containerRef.value);
1964
2239
  if (!isVisible) {
2240
+ // 标记这是定位滚动,不应该被同步滚动干扰
2241
+ containerRef.value.dataset.pageScrolling = 'true';
1965
2242
  elementRef.scrollIntoView({ behavior: "smooth", block: "center" });
2243
+ // 延迟清除标记,确保滚动完成
2244
+ setTimeout(() => {
2245
+ if (containerRef.value) {
2246
+ delete containerRef.value.dataset.pageScrolling;
2247
+ }
2248
+ }, 500); // scrollIntoView 的 smooth 动画通常需要 300-500ms
1966
2249
  }
1967
2250
  }
1968
2251
 
@@ -1998,7 +2281,12 @@ const jumpToPosition = (
1998
2281
  return;
1999
2282
  }
2000
2283
 
2001
- // 等待DOM更新后再高亮
2284
+ // 先跳转到对应页面(如果不在当前页)
2285
+ if (pageNum !== currentPage.value) {
2286
+ switchToPage(pageNum);
2287
+ }
2288
+
2289
+ // 等待页面跳转完成后再高亮
2002
2290
  nextTick(() => {
2003
2291
  let retryCount = 0;
2004
2292
  const maxRetries = 5;
@@ -2019,7 +2307,8 @@ const jumpToPosition = (
2019
2307
  }
2020
2308
  };
2021
2309
 
2022
- setTimeout(tryHighlight, 300);
2310
+ // 增加延迟,确保页面跳转完成(特别是同步滚动场景)
2311
+ setTimeout(tryHighlight, 500);
2023
2312
  });
2024
2313
  };
2025
2314
 
@@ -2094,6 +2383,99 @@ watch(
2094
2383
  { deep: true }
2095
2384
  );
2096
2385
 
2386
+ /**
2387
+ * 处理容器尺寸变化,重新计算自适应缩放比例
2388
+ */
2389
+ const handleContainerResize = () => {
2390
+ // 如果禁用了自适应宽度,或者用户主动缩放过,不自动调整
2391
+ if (!props.autoFitWidth || isUserZooming.value) {
2392
+ return;
2393
+ }
2394
+
2395
+ // 如果第一页图片还没有加载完成,不处理
2396
+ const firstPageSize = imageSizes.get(1);
2397
+ if (!firstPageSize || firstPageSize.width === 0) {
2398
+ return;
2399
+ }
2400
+
2401
+ // 如果正在计算中或正在处理 resize,跳过(避免重复计算)
2402
+ if (isCalculatingAutoFit.value || isResizing) {
2403
+ return;
2404
+ }
2405
+
2406
+ // 检查容器尺寸是否真的变化了(避免无意义的重复计算)
2407
+ if (containerRef.value) {
2408
+ const currentWidth = containerRef.value.getBoundingClientRect().width;
2409
+ // 如果宽度变化小于 5px,认为是渲染抖动,不处理
2410
+ if (Math.abs(currentWidth - lastContainerWidth) < 5) {
2411
+ return;
2412
+ }
2413
+ lastContainerWidth = currentWidth;
2414
+ }
2415
+
2416
+ console.log('[ImagePreview] handleContainerResize 被调用:', {
2417
+ autoFitWidth: props.autoFitWidth,
2418
+ isUserZooming: isUserZooming.value,
2419
+ isImageReady: isImageReady.value,
2420
+ isCalculatingAutoFit: isCalculatingAutoFit.value,
2421
+ hasFirstPageSize: !!imageSizes.get(1),
2422
+ containerWidth: containerRef.value?.getBoundingClientRect()?.width,
2423
+ });
2424
+
2425
+ // 标记正在处理 resize
2426
+ isResizing = true;
2427
+
2428
+ // 清除之前的定时器
2429
+ if (resizeTimer) {
2430
+ clearTimeout(resizeTimer);
2431
+ resizeTimer = null;
2432
+ }
2433
+
2434
+ // 隐藏图片,显示 loading
2435
+ console.log('[ImagePreview] handleContainerResize: 开始重新计算');
2436
+ isImageReady.value = false;
2437
+ isCalculatingAutoFit.value = true;
2438
+
2439
+ // 立即计算并应用新的缩放比例,避免过渡期间露出底色
2440
+ // 使用 requestAnimationFrame 确保在浏览器重绘前更新
2441
+ requestAnimationFrame(() => {
2442
+ try {
2443
+ console.log('[ImagePreview] handleContainerResize: 开始计算自适应宽度...');
2444
+ const newScale = calculateAutoFitScale();
2445
+ console.log('[ImagePreview] handleContainerResize: 计算结果:', newScale);
2446
+
2447
+ if (newScale > 0) {
2448
+ // 即使变化很小也立即更新,确保过渡期间图片始终填满容器
2449
+ scale.value = newScale;
2450
+ initialAutoFitScale.value = newScale;
2451
+ }
2452
+ } catch (error) {
2453
+ console.error('[ImagePreview] handleContainerResize: 计算失败:', error);
2454
+ }
2455
+
2456
+ // 在过渡动画完成后再次检查,确保最终状态正确(处理过渡动画期间的连续变化)
2457
+ resizeTimer = setTimeout(() => {
2458
+ try {
2459
+ const finalScale = calculateAutoFitScale();
2460
+ if (finalScale > 0 && Math.abs(finalScale - scale.value) > 0.01) {
2461
+ scale.value = finalScale;
2462
+ initialAutoFitScale.value = finalScale;
2463
+ }
2464
+ } catch (error) {
2465
+ console.error('[ImagePreview] handleContainerResize: 最终计算失败:', error);
2466
+ } finally {
2467
+ // 计算完成后,显示图片并隐藏 loading
2468
+ console.log('[ImagePreview] handleContainerResize: 更新状态完成');
2469
+ isCalculatingAutoFit.value = false;
2470
+ isResizing = false; // 重置标记
2471
+ requestAnimationFrame(() => {
2472
+ isImageReady.value = true;
2473
+ });
2474
+ }
2475
+ }, 350); // 350ms 延迟,略大于过渡动画时间(300ms),确保过渡完成后稳定
2476
+ });
2477
+ };
2478
+
2097
2479
  /**
2098
2480
  * 组件挂载时的初始化
2099
2481
  */
@@ -2103,19 +2485,68 @@ onMounted(() => {
2103
2485
  if (firstPageImage && firstPageImage.complete && props.autoFitWidth) {
2104
2486
  const firstPageSize = imageSizes.get(1);
2105
2487
  if (firstPageSize && firstPageSize.width > 0) {
2488
+ // 隐藏图片,显示 loading
2489
+ isImageReady.value = false;
2490
+ isCalculatingAutoFit.value = true;
2491
+
2492
+ // 设置超时保护,防止一直显示 loading(最多等待 3 秒)
2493
+ const timeoutId = setTimeout(() => {
2494
+ console.warn('自适应宽度计算超时,强制显示图片');
2495
+ isCalculatingAutoFit.value = false;
2496
+ isImageReady.value = true;
2497
+ }, 3000);
2498
+
2106
2499
  nextTick(() => {
2107
2500
  nextTick(() => {
2108
2501
  setTimeout(() => {
2109
- const autoScale = calculateAutoFitScale();
2110
- if (autoScale !== 1 && autoScale > 0) {
2111
- scale.value = autoScale;
2112
- initialAutoFitScale.value = autoScale;
2502
+ try {
2503
+ const autoScale = calculateAutoFitScale();
2504
+ if (autoScale > 0) {
2505
+ scale.value = autoScale;
2506
+ initialAutoFitScale.value = autoScale;
2507
+ }
2508
+ } catch (error) {
2509
+ console.warn('计算自适应宽度失败:', error);
2510
+ } finally {
2511
+ // 清除超时保护
2512
+ clearTimeout(timeoutId);
2513
+ // 无论计算结果如何,都要更新状态,避免一直显示 loading
2514
+ isCalculatingAutoFit.value = false;
2515
+ requestAnimationFrame(() => {
2516
+ isImageReady.value = true;
2517
+ });
2113
2518
  }
2114
2519
  }, 100);
2115
2520
  });
2116
2521
  });
2117
2522
  }
2523
+ } else if (!props.autoFitWidth) {
2524
+ // 如果没有启用自适应宽度,立即显示图片
2525
+ isImageReady.value = true;
2118
2526
  }
2527
+
2528
+ // 监听容器尺寸变化(用于响应外部收起/展开操作)
2529
+ nextTick(() => {
2530
+ if (containerRef.value && typeof ResizeObserver !== 'undefined') {
2531
+ resizeObserver = new ResizeObserver((entries) => {
2532
+ // 使用防抖,避免频繁触发
2533
+ if (resizeDebounceTimer) {
2534
+ clearTimeout(resizeDebounceTimer);
2535
+ }
2536
+ resizeDebounceTimer = setTimeout(() => {
2537
+ // 使用 entries 参数立即获取新尺寸,避免延迟
2538
+ for (const entry of entries) {
2539
+ // 立即响应尺寸变化,避免过渡期间露出底色
2540
+ handleContainerResize();
2541
+ }
2542
+ }, 100); // 100ms 防抖,避免频繁触发
2543
+ });
2544
+ resizeObserver.observe(containerRef.value);
2545
+ } else {
2546
+ // 降级方案:监听窗口大小变化
2547
+ window.addEventListener('resize', handleContainerResize);
2548
+ }
2549
+ });
2119
2550
  });
2120
2551
 
2121
2552
  /**
@@ -2130,6 +2561,20 @@ onBeforeUnmount(() => {
2130
2561
  clearTimeout(scrollPagingTimer);
2131
2562
  scrollPagingTimer = null;
2132
2563
  }
2564
+ if (resizeTimer) {
2565
+ clearTimeout(resizeTimer);
2566
+ resizeTimer = null;
2567
+ }
2568
+ if (resizeDebounceTimer) {
2569
+ clearTimeout(resizeDebounceTimer);
2570
+ resizeDebounceTimer = null;
2571
+ }
2572
+ if (resizeObserver) {
2573
+ resizeObserver.disconnect();
2574
+ resizeObserver = null;
2575
+ }
2576
+ // 移除窗口 resize 监听器(降级方案)
2577
+ window.removeEventListener('resize', handleContainerResize);
2133
2578
  });
2134
2579
 
2135
2580
  defineExpose({
@@ -2138,6 +2583,7 @@ defineExpose({
2138
2583
  goToPage: switchToPage, // 暴露翻页方法给父组件
2139
2584
  getCurrentPage: () => currentPage.value, // 获取当前页码
2140
2585
  getTotalPages: () => totalPages.value, // 获取总页数
2586
+ getContainer: () => containerRef.value, // 暴露容器引用,用于同步滚动
2141
2587
  });
2142
2588
  </script>
2143
2589
 
@@ -2156,6 +2602,47 @@ defineExpose({
2156
2602
  overflow: auto;
2157
2603
  }
2158
2604
 
2605
+ // 自适应宽度计算 Loading
2606
+ .auto-fit-loading {
2607
+ position: absolute;
2608
+ top: 50%;
2609
+ left: 50%;
2610
+ transform: translate(-50%, -50%);
2611
+ z-index: 1000;
2612
+ display: flex;
2613
+ flex-direction: column;
2614
+ align-items: center;
2615
+ gap: 12px;
2616
+ padding: 20px 30px;
2617
+ }
2618
+
2619
+ .loading-spinner {
2620
+ width: 32px;
2621
+ height: 32px;
2622
+ border: 3px solid #e5e7eb;
2623
+ border-top-color: #1890ff;
2624
+ border-radius: 50%;
2625
+ animation: spin 0.8s linear infinite;
2626
+ }
2627
+
2628
+ @keyframes spin {
2629
+ to {
2630
+ transform: rotate(360deg);
2631
+ }
2632
+ }
2633
+
2634
+ .loading-text {
2635
+ font-size: 14px;
2636
+ color: #666;
2637
+ white-space: nowrap;
2638
+ }
2639
+
2640
+ // 图片隐藏状态(自适应宽度计算完成前)
2641
+ // 使用 display: none 确保图片完全不可见,不会在加载过程中显示
2642
+ .image-wrapper-container.image-hidden {
2643
+ display: none !important;
2644
+ }
2645
+
2159
2646
  // 页码信息样式
2160
2647
  .page-info {
2161
2648
  display: inline-flex;
@@ -2208,6 +2695,8 @@ defineExpose({
2208
2695
  margin: 0;
2209
2696
  padding: 0;
2210
2697
  line-height: 0;
2698
+ // 添加平滑过渡,避免切换时露出底色
2699
+ transition: transform 0.3s ease;
2211
2700
 
2212
2701
  img {
2213
2702
  display: block; // 移除图片底部默认间隙