@koi-br/ocr-web-sdk 1.0.20 → 1.0.22
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.
- package/dist/{index-WSXL-n78.mjs → index-B7vUXfzK.mjs} +8428 -8193
- package/dist/{index-CBQ568pM.js → index-DQKsWq_p.js} +83 -80
- package/dist/index.cjs.js +2 -2
- package/dist/index.esm.js +2 -2
- package/dist/preview/ImagePreview.vue.d.ts +11 -0
- package/dist/{tiff.min-CgtwxxRj.js → tiff.min-CotNPbgn.js} +1 -1
- package/dist/{tiff.min-B-Qg8e2o.mjs → tiff.min-KyZyepKd.mjs} +1 -1
- package/package.json +1 -1
- package/preview/ImagePreview.vue +778 -22
- package/preview/PdfPreview.vue +1 -1
- package/preview/index.vue +12 -1
package/preview/ImagePreview.vue
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div
|
|
3
3
|
class="preview-container"
|
|
4
|
-
|
|
4
|
+
:class="{ 'wheel-zoom-enabled': enableWheelZoom }"
|
|
5
|
+
@wheel="handleWheel"
|
|
5
6
|
>
|
|
6
7
|
<!-- 工具栏 -->
|
|
7
8
|
<div class="preview-toolbar preview-toolbar-between">
|
|
@@ -93,32 +94,80 @@
|
|
|
93
94
|
</div>
|
|
94
95
|
|
|
95
96
|
<div
|
|
96
|
-
|
|
97
|
+
ref="containerRef"
|
|
98
|
+
class="image-container"
|
|
97
99
|
@mousedown="startPan"
|
|
98
100
|
@mousemove="pan"
|
|
99
101
|
@mouseup="stopPan"
|
|
100
102
|
@mouseleave="stopPan"
|
|
103
|
+
@scroll="handleScroll"
|
|
101
104
|
>
|
|
102
|
-
<
|
|
103
|
-
|
|
104
|
-
:
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
105
|
+
<div
|
|
106
|
+
class="image-wrapper-container"
|
|
107
|
+
:style="containerStyle"
|
|
108
|
+
>
|
|
109
|
+
<div
|
|
110
|
+
class="image-wrapper"
|
|
111
|
+
:style="{
|
|
112
|
+
transform: `translate(${position.x}px, ${position.y}px) rotate(${rotation}deg) scale(${scale})`,
|
|
113
|
+
transformOrigin: 'center center'
|
|
114
|
+
}"
|
|
115
|
+
>
|
|
116
|
+
<img
|
|
117
|
+
v-if="url"
|
|
118
|
+
ref="imageRef"
|
|
119
|
+
:src="url"
|
|
120
|
+
alt="预览图片"
|
|
121
|
+
:style="{
|
|
122
|
+
cursor: isPanning ? 'grabbing' : 'grab',
|
|
123
|
+
display: 'block'
|
|
109
124
|
}"
|
|
110
125
|
@contextmenu.prevent
|
|
111
126
|
@mousedown.prevent
|
|
112
127
|
@load="onImageLoad"
|
|
113
128
|
/>
|
|
129
|
+
|
|
130
|
+
<!-- 文本图层(用于文本块选择和定位) -->
|
|
131
|
+
<div
|
|
132
|
+
v-if="blocksData && blocksData.length > 0"
|
|
133
|
+
ref="textLayerRef"
|
|
134
|
+
class="text-layer"
|
|
135
|
+
></div>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<!-- 复制按钮浮层 -->
|
|
140
|
+
<div
|
|
141
|
+
v-if="showCopyButton"
|
|
142
|
+
ref="copyButtonRef"
|
|
143
|
+
class="copy-button-popup"
|
|
144
|
+
:style="copyButtonStyle"
|
|
145
|
+
@mouseenter="cancelHideCopyButton"
|
|
146
|
+
@mouseleave="hideCopyButtonAndHighlight"
|
|
147
|
+
>
|
|
148
|
+
<div class="copy-button-popup-action" @click="copySelectedText">
|
|
149
|
+
<Copy :size="12" style="margin-right: 4px" />
|
|
150
|
+
复制
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
114
153
|
</div>
|
|
115
154
|
</div>
|
|
116
155
|
</template>
|
|
117
156
|
|
|
118
|
-
<script setup>
|
|
119
|
-
import { ref } from 'vue';
|
|
120
|
-
import { ZoomIn, ZoomOut, RefreshCcw, RotateCw, RotateCcw, Download, Maximize2 } from 'lucide-vue-next';
|
|
121
|
-
import { Button as AButton, Tooltip as ATooltip } from '@arco-design/web-vue';
|
|
157
|
+
<script setup lang="ts">
|
|
158
|
+
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';
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 文本块数据结构(与 PdfPreview 保持一致)
|
|
164
|
+
*/
|
|
165
|
+
interface BlockInfo {
|
|
166
|
+
blockLabel: string; // 块标签
|
|
167
|
+
blockContent: string; // 块内容
|
|
168
|
+
blockBbox: [number, number, number, number]; // 块的边界框 [x1, y1, x2, y2]
|
|
169
|
+
blockPage?: number; // 块所属的页码(图片预览中通常为1,可选)
|
|
170
|
+
}
|
|
122
171
|
|
|
123
172
|
const props = defineProps({
|
|
124
173
|
url: {
|
|
@@ -148,10 +197,23 @@ const props = defineProps({
|
|
|
148
197
|
isDownload: {
|
|
149
198
|
type: Boolean,
|
|
150
199
|
default: false
|
|
200
|
+
},
|
|
201
|
+
blocksData: {
|
|
202
|
+
type: Array as () => BlockInfo[],
|
|
203
|
+
default: () => []
|
|
204
|
+
},
|
|
205
|
+
enableWheelZoom: {
|
|
206
|
+
type: Boolean,
|
|
207
|
+
default: true // 默认启用滚轮缩放,保持向后兼容
|
|
151
208
|
}
|
|
152
209
|
});
|
|
153
210
|
|
|
154
|
-
const emit = defineEmits
|
|
211
|
+
const emit = defineEmits<{
|
|
212
|
+
'original': [];
|
|
213
|
+
'download': [];
|
|
214
|
+
'load': [data: { width: number; height: number }];
|
|
215
|
+
'position-jump': [bbox: [number, number, number, number], type: 'block' | 'seal'];
|
|
216
|
+
}>();
|
|
155
217
|
|
|
156
218
|
const rotation = ref(0);
|
|
157
219
|
const scale = ref(1);
|
|
@@ -159,6 +221,56 @@ const position = ref({ x: 0, y: 0 });
|
|
|
159
221
|
const isPanning = ref(false);
|
|
160
222
|
const lastPosition = ref({ x: 0, y: 0 });
|
|
161
223
|
|
|
224
|
+
// 图片和容器引用
|
|
225
|
+
const containerRef = ref<HTMLElement>();
|
|
226
|
+
const imageRef = ref<HTMLImageElement>();
|
|
227
|
+
const textLayerRef = ref<HTMLElement>();
|
|
228
|
+
const copyButtonRef = ref<HTMLElement>();
|
|
229
|
+
|
|
230
|
+
// 图片尺寸
|
|
231
|
+
const imageSize = ref({ width: 0, height: 0 });
|
|
232
|
+
|
|
233
|
+
// 计算图片放大后的实际尺寸(考虑旋转)
|
|
234
|
+
const scaledImageSize = computed(() => {
|
|
235
|
+
if (imageSize.value.width === 0 || imageSize.value.height === 0) {
|
|
236
|
+
return { width: 0, height: 0 };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const { width, height } = imageSize.value;
|
|
240
|
+
const scaledWidth = width * scale.value;
|
|
241
|
+
const scaledHeight = height * scale.value;
|
|
242
|
+
|
|
243
|
+
// 如果旋转了 90 度或 270 度,交换宽高
|
|
244
|
+
const normalizedRotation = ((rotation.value % 360) + 360) % 360;
|
|
245
|
+
const isRotated = normalizedRotation === 90 || normalizedRotation === 270;
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
width: isRotated ? scaledHeight : scaledWidth,
|
|
249
|
+
height: isRotated ? scaledWidth : scaledHeight
|
|
250
|
+
};
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// 容器样式,动态设置最小尺寸以支持滚动
|
|
254
|
+
const containerStyle = computed(() => {
|
|
255
|
+
const { width, height } = scaledImageSize.value;
|
|
256
|
+
const padding = 40; // 左右各20px,共40px
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
minWidth: width > 0 ? `${width + padding}px` : '100%',
|
|
260
|
+
minHeight: height > 0 ? `${height + padding}px` : '100%'
|
|
261
|
+
};
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// 文本选择相关状态
|
|
265
|
+
const showCopyButton = ref(false);
|
|
266
|
+
const selectedText = ref('');
|
|
267
|
+
const copyButtonStyle = ref<any>({});
|
|
268
|
+
const activeBlockDiv = ref<HTMLElement | null>(null);
|
|
269
|
+
const isHighlighted = ref(false); // 标识是否为定位点击的高亮
|
|
270
|
+
|
|
271
|
+
// 隐藏定时器
|
|
272
|
+
let hideTimer: any = null;
|
|
273
|
+
|
|
162
274
|
const rotateImage = direction => {
|
|
163
275
|
if (direction === 'left') {
|
|
164
276
|
rotation.value = (rotation.value - 90) % 360;
|
|
@@ -179,19 +291,32 @@ const zoom = (delta, isWheel = false) => {
|
|
|
179
291
|
}
|
|
180
292
|
};
|
|
181
293
|
|
|
182
|
-
const handleWheel = e => {
|
|
294
|
+
const handleWheel = (e: WheelEvent) => {
|
|
295
|
+
// 如果禁用了滚轮缩放,不阻止默认行为,允许正常滚动
|
|
296
|
+
if (!props.enableWheelZoom) {
|
|
297
|
+
return; // 不调用 preventDefault,允许默认滚动行为
|
|
298
|
+
}
|
|
299
|
+
// 启用滚轮缩放时,阻止默认行为并执行缩放
|
|
183
300
|
e.preventDefault();
|
|
184
301
|
const delta = e.deltaY > 0 ? -1 : 1;
|
|
185
302
|
zoom(delta, true);
|
|
186
303
|
};
|
|
187
304
|
|
|
188
|
-
const startPan = e => {
|
|
305
|
+
const startPan = (e: MouseEvent) => {
|
|
306
|
+
// 只在左键点击时启用拖拽
|
|
189
307
|
if (e.button !== 0) return;
|
|
308
|
+
// 如果按住 Ctrl 或 Meta 键,允许拖拽(避免与文本选择冲突)
|
|
309
|
+
// 如果没有文本图层数据,或者按住修饰键,允许拖拽
|
|
310
|
+
if (props.blocksData && props.blocksData.length > 0 && !e.ctrlKey && !e.metaKey) {
|
|
311
|
+
// 有文本图层时,默认不拖拽,允许文本选择
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
190
314
|
isPanning.value = true;
|
|
191
315
|
lastPosition.value = { x: e.clientX, y: e.clientY };
|
|
316
|
+
e.preventDefault(); // 阻止默认行为,避免与滚动冲突
|
|
192
317
|
};
|
|
193
318
|
|
|
194
|
-
const pan = e => {
|
|
319
|
+
const pan = (e: MouseEvent) => {
|
|
195
320
|
if (!isPanning.value) return;
|
|
196
321
|
|
|
197
322
|
const deltaX = e.clientX - lastPosition.value.x;
|
|
@@ -203,6 +328,7 @@ const pan = e => {
|
|
|
203
328
|
};
|
|
204
329
|
|
|
205
330
|
lastPosition.value = { x: e.clientX, y: e.clientY };
|
|
331
|
+
e.preventDefault(); // 拖拽时阻止默认行为
|
|
206
332
|
};
|
|
207
333
|
|
|
208
334
|
const stopPan = () => {
|
|
@@ -213,6 +339,12 @@ const reset = () => {
|
|
|
213
339
|
rotation.value = 0;
|
|
214
340
|
scale.value = 1;
|
|
215
341
|
position.value = { x: 0, y: 0 };
|
|
342
|
+
|
|
343
|
+
// 重置文本选择状态
|
|
344
|
+
showCopyButton.value = false;
|
|
345
|
+
selectedText.value = '';
|
|
346
|
+
activeBlockDiv.value = null;
|
|
347
|
+
isHighlighted.value = false;
|
|
216
348
|
};
|
|
217
349
|
|
|
218
350
|
const original = () => {
|
|
@@ -220,29 +352,653 @@ const original = () => {
|
|
|
220
352
|
};
|
|
221
353
|
|
|
222
354
|
// 图片加载完成处理
|
|
223
|
-
const onImageLoad = (event) => {
|
|
224
|
-
const img = event.target;
|
|
355
|
+
const onImageLoad = (event: Event) => {
|
|
356
|
+
const img = event.target as HTMLImageElement;
|
|
357
|
+
imageSize.value = {
|
|
358
|
+
width: img.naturalWidth,
|
|
359
|
+
height: img.naturalHeight
|
|
360
|
+
};
|
|
225
361
|
emit('load', {
|
|
226
362
|
width: img.naturalWidth,
|
|
227
363
|
height: img.naturalHeight
|
|
228
364
|
});
|
|
365
|
+
|
|
366
|
+
// 图片加载完成后,渲染文本图层
|
|
367
|
+
nextTick(() => {
|
|
368
|
+
renderTextLayer();
|
|
369
|
+
});
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* 渲染文本图层(使用 blocksData 数据)
|
|
374
|
+
*/
|
|
375
|
+
const renderTextLayer = () => {
|
|
376
|
+
const textLayer = textLayerRef.value;
|
|
377
|
+
const image = imageRef.value;
|
|
378
|
+
|
|
379
|
+
if (!textLayer || !image) {
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// 如果没有提供分块数据,跳过渲染
|
|
384
|
+
if (!props.blocksData || props.blocksData.length === 0) {
|
|
385
|
+
textLayer.innerHTML = '';
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
try {
|
|
390
|
+
// 设置文本图层的尺寸与图片尺寸一致
|
|
391
|
+
textLayer.style.width = `${image.naturalWidth}px`;
|
|
392
|
+
textLayer.style.height = `${image.naturalHeight}px`;
|
|
393
|
+
|
|
394
|
+
// 清空文本图层
|
|
395
|
+
textLayer.innerHTML = '';
|
|
396
|
+
|
|
397
|
+
// 使用 blocksData 创建可交互的块
|
|
398
|
+
props.blocksData.forEach((block, index) => {
|
|
399
|
+
const { blockLabel, blockContent, blockBbox } = block;
|
|
400
|
+
|
|
401
|
+
// blockBbox 格式: [x1, y1, x2, y2]
|
|
402
|
+
const [x1, y1, x2, y2] = blockBbox;
|
|
403
|
+
const width = x2 - x1;
|
|
404
|
+
const height = y2 - y1;
|
|
405
|
+
|
|
406
|
+
// 创建文本块
|
|
407
|
+
const blockDiv = document.createElement('div');
|
|
408
|
+
blockDiv.className = 'text-block';
|
|
409
|
+
blockDiv.dataset.text = blockContent;
|
|
410
|
+
blockDiv.dataset.label = blockLabel;
|
|
411
|
+
blockDiv.dataset.bbox = JSON.stringify(blockBbox);
|
|
412
|
+
|
|
413
|
+
// 设置基础样式
|
|
414
|
+
blockDiv.style.position = 'absolute';
|
|
415
|
+
blockDiv.style.left = `${x1}px`;
|
|
416
|
+
blockDiv.style.top = `${y1}px`;
|
|
417
|
+
blockDiv.style.width = `${width}px`;
|
|
418
|
+
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
|
+
|
|
442
|
+
// 设置当前文本块为激活状态
|
|
443
|
+
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);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
blockDiv.addEventListener('mouseleave', () => {
|
|
455
|
+
// 延迟隐藏,给用户时间移动到复制按钮
|
|
456
|
+
hideCopyButtonAndHighlight();
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
textLayer.appendChild(blockDiv);
|
|
460
|
+
});
|
|
461
|
+
} catch (error) {
|
|
462
|
+
console.error('❌ 文本图层渲染失败:', error);
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* 显示文本块的复制按钮
|
|
468
|
+
*/
|
|
469
|
+
const showCopyButtonForBlock = (event: MouseEvent, blockDiv: HTMLElement) => {
|
|
470
|
+
const text = blockDiv.dataset.text || '';
|
|
471
|
+
if (!text) return;
|
|
472
|
+
|
|
473
|
+
selectedText.value = text;
|
|
474
|
+
|
|
475
|
+
const rect = blockDiv.getBoundingClientRect();
|
|
476
|
+
const containerRect = containerRef.value?.getBoundingClientRect();
|
|
477
|
+
|
|
478
|
+
if (!containerRect || !containerRef.value) return;
|
|
479
|
+
|
|
480
|
+
// 计算复制按钮的位置
|
|
481
|
+
const buttonCount = 1;
|
|
482
|
+
const buttonWidth = 80;
|
|
483
|
+
const popupWidth = buttonCount * buttonWidth + 8;
|
|
484
|
+
const popupHeight = 38;
|
|
485
|
+
const spacing = 8;
|
|
486
|
+
|
|
487
|
+
// 计算相对于容器的坐标(考虑滚动)
|
|
488
|
+
const relativeX = rect.left - containerRect.left + containerRef.value.scrollLeft;
|
|
489
|
+
const relativeY = rect.top - containerRect.top + containerRef.value.scrollTop;
|
|
490
|
+
|
|
491
|
+
// 容器可用空间
|
|
492
|
+
const containerWidth = containerRef.value.clientWidth;
|
|
493
|
+
|
|
494
|
+
// 计算可用空间
|
|
495
|
+
const rectBottom = rect.bottom;
|
|
496
|
+
const rectTop = rect.top;
|
|
497
|
+
const containerBottom = containerRect.bottom;
|
|
498
|
+
const containerTop = containerRect.top;
|
|
499
|
+
|
|
500
|
+
const bottomSpace = containerBottom - rectBottom;
|
|
501
|
+
const topSpace = rectTop - containerTop;
|
|
502
|
+
|
|
503
|
+
const showOnBottom = bottomSpace >= popupHeight + spacing || (bottomSpace > 0 && bottomSpace >= topSpace);
|
|
504
|
+
|
|
505
|
+
// 计算最终位置
|
|
506
|
+
let left: number;
|
|
507
|
+
let top: number;
|
|
508
|
+
|
|
509
|
+
const centerX = relativeX + rect.width / 2;
|
|
510
|
+
left = centerX - popupWidth / 2;
|
|
511
|
+
|
|
512
|
+
if (showOnBottom) {
|
|
513
|
+
top = relativeY + rect.height + spacing;
|
|
514
|
+
} else {
|
|
515
|
+
top = relativeY - popupHeight - spacing;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// 边界检查
|
|
519
|
+
const popupTopInViewport = top - containerRef.value.scrollTop + containerRect.top;
|
|
520
|
+
const popupBottomInViewport = popupTopInViewport + popupHeight;
|
|
521
|
+
|
|
522
|
+
if (popupBottomInViewport > containerBottom - spacing) {
|
|
523
|
+
top = relativeY - popupHeight - spacing;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const newPopupTopInViewport = top - containerRef.value.scrollTop + containerRect.top;
|
|
527
|
+
if (newPopupTopInViewport < containerTop + spacing) {
|
|
528
|
+
top = relativeY + rect.height + spacing;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// 确保不超出容器边界
|
|
532
|
+
if (left + popupWidth > containerWidth - spacing) {
|
|
533
|
+
left = containerWidth - popupWidth - spacing;
|
|
534
|
+
}
|
|
535
|
+
if (left < spacing) {
|
|
536
|
+
left = spacing;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
copyButtonStyle.value = {
|
|
540
|
+
left: `${left}px`,
|
|
541
|
+
top: `${top}px`
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
showCopyButton.value = true;
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* 隐藏复制按钮并移除文本块高亮
|
|
549
|
+
*/
|
|
550
|
+
const hideCopyButtonAndHighlight = () => {
|
|
551
|
+
hideTimer = setTimeout(() => {
|
|
552
|
+
showCopyButton.value = false;
|
|
553
|
+
|
|
554
|
+
if (activeBlockDiv.value && !isHighlighted.value) {
|
|
555
|
+
activeBlockDiv.value.style.backgroundColor = 'transparent';
|
|
556
|
+
activeBlockDiv.value.style.boxShadow = 'none';
|
|
557
|
+
activeBlockDiv.value = null;
|
|
558
|
+
}
|
|
559
|
+
}, 300);
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* 取消隐藏(鼠标进入复制按钮时调用)
|
|
564
|
+
*/
|
|
565
|
+
const cancelHideCopyButton = () => {
|
|
566
|
+
if (hideTimer) {
|
|
567
|
+
clearTimeout(hideTimer);
|
|
568
|
+
hideTimer = null;
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* 将HTML表格转换为纯文本格式
|
|
574
|
+
*/
|
|
575
|
+
const convertHtmlTableToText = (htmlString: string): string => {
|
|
576
|
+
if (!/<[^>]+>/g.test(htmlString)) {
|
|
577
|
+
return htmlString;
|
|
578
|
+
}
|
|
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;
|
|
588
|
+
}
|
|
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
|
+
}
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* 复制选中的文本
|
|
623
|
+
*/
|
|
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
|
+
}
|
|
635
|
+
} else {
|
|
636
|
+
fallbackCopyText(textToCopy);
|
|
637
|
+
}
|
|
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;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
window.getSelection()?.removeAllRanges();
|
|
648
|
+
} catch (error) {
|
|
649
|
+
console.error('复制失败:', error);
|
|
650
|
+
Message.error('复制失败,请手动选择并复制文本');
|
|
651
|
+
}
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* 降级复制方案
|
|
656
|
+
*/
|
|
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';
|
|
665
|
+
|
|
666
|
+
document.body.appendChild(textarea);
|
|
667
|
+
|
|
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);
|
|
678
|
+
}
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* 处理滚动事件(隐藏复制按钮和文本块高亮)
|
|
683
|
+
*/
|
|
684
|
+
const handleScroll = () => {
|
|
685
|
+
if (hideTimer) {
|
|
686
|
+
clearTimeout(hideTimer);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
showCopyButton.value = false;
|
|
690
|
+
|
|
691
|
+
if (activeBlockDiv.value && !isHighlighted.value) {
|
|
692
|
+
activeBlockDiv.value.style.backgroundColor = 'transparent';
|
|
693
|
+
activeBlockDiv.value.style.boxShadow = 'none';
|
|
694
|
+
activeBlockDiv.value = null;
|
|
695
|
+
}
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* 检查元素是否在视口内可见
|
|
700
|
+
*/
|
|
701
|
+
const isElementVisible = (element: HTMLElement, container: HTMLElement): boolean => {
|
|
702
|
+
const elementRect = element.getBoundingClientRect();
|
|
703
|
+
const containerRect = container.getBoundingClientRect();
|
|
704
|
+
|
|
705
|
+
return (
|
|
706
|
+
elementRect.top < containerRect.bottom &&
|
|
707
|
+
elementRect.bottom > containerRect.top &&
|
|
708
|
+
elementRect.left < containerRect.right &&
|
|
709
|
+
elementRect.right > containerRect.left
|
|
710
|
+
);
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* 高亮指定位置
|
|
715
|
+
* @param bbox 块的边界框 [x1, y1, x2, y2]
|
|
716
|
+
* @param shouldScroll 是否应该滚动到元素位置
|
|
717
|
+
* @returns 是否成功找到并高亮了元素
|
|
718
|
+
*/
|
|
719
|
+
const highlightPosition = (
|
|
720
|
+
bbox: [number, number, number, number],
|
|
721
|
+
shouldScroll: boolean = true
|
|
722
|
+
): boolean => {
|
|
723
|
+
// 清除之前的高亮
|
|
724
|
+
if (activeBlockDiv.value) {
|
|
725
|
+
activeBlockDiv.value.style.backgroundColor = 'transparent';
|
|
726
|
+
activeBlockDiv.value.style.boxShadow = 'none';
|
|
727
|
+
activeBlockDiv.value = null;
|
|
728
|
+
}
|
|
729
|
+
isHighlighted.value = false;
|
|
730
|
+
|
|
731
|
+
const textLayer = textLayerRef.value;
|
|
732
|
+
if (!textLayer) return false;
|
|
733
|
+
|
|
734
|
+
// 查找对应的块元素,使用存储的bbox数据匹配(使用容差)
|
|
735
|
+
const blockDivs = textLayer.querySelectorAll('.text-block');
|
|
736
|
+
const tolerance = 2;
|
|
737
|
+
|
|
738
|
+
let matchedElement: HTMLElement | null = null;
|
|
739
|
+
|
|
740
|
+
blockDivs.forEach((div) => {
|
|
741
|
+
const el = div as HTMLElement;
|
|
742
|
+
const storedBbox = el.dataset.bbox;
|
|
743
|
+
if (!storedBbox) return;
|
|
744
|
+
|
|
745
|
+
try {
|
|
746
|
+
const parsedBbox = JSON.parse(storedBbox) as [number, number, number, number];
|
|
747
|
+
|
|
748
|
+
const isMatch =
|
|
749
|
+
Math.abs(parsedBbox[0] - bbox[0]) < tolerance &&
|
|
750
|
+
Math.abs(parsedBbox[1] - bbox[1]) < tolerance &&
|
|
751
|
+
Math.abs(parsedBbox[2] - bbox[2]) < tolerance &&
|
|
752
|
+
Math.abs(parsedBbox[3] - bbox[3]) < tolerance;
|
|
753
|
+
|
|
754
|
+
if (isMatch) {
|
|
755
|
+
matchedElement = el;
|
|
756
|
+
}
|
|
757
|
+
} catch (error) {
|
|
758
|
+
console.warn('解析bbox失败:', error);
|
|
759
|
+
}
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
if (!matchedElement) return false;
|
|
763
|
+
|
|
764
|
+
// 保存引用
|
|
765
|
+
const elementRef = matchedElement;
|
|
766
|
+
|
|
767
|
+
activeBlockDiv.value = elementRef;
|
|
768
|
+
isHighlighted.value = true;
|
|
769
|
+
|
|
770
|
+
// 使用一致的高亮样式
|
|
771
|
+
elementRef.style.backgroundColor = 'var(--s-color-brand-primary-transparent-3, rgba(0, 102, 255, .15))';
|
|
772
|
+
elementRef.style.boxShadow = '0 0 0 2px rgba(30, 144, 255, 0.6)';
|
|
773
|
+
|
|
774
|
+
// 只有在需要滚动且元素不在视口内时才滚动
|
|
775
|
+
if (shouldScroll && containerRef.value) {
|
|
776
|
+
const isVisible = isElementVisible(elementRef, containerRef.value);
|
|
777
|
+
if (!isVisible) {
|
|
778
|
+
elementRef.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// 5秒后自动取消高亮
|
|
783
|
+
setTimeout(() => {
|
|
784
|
+
if (activeBlockDiv.value === elementRef && isHighlighted.value) {
|
|
785
|
+
elementRef.style.backgroundColor = 'transparent';
|
|
786
|
+
elementRef.style.boxShadow = 'none';
|
|
787
|
+
activeBlockDiv.value = null;
|
|
788
|
+
isHighlighted.value = false;
|
|
789
|
+
}
|
|
790
|
+
}, 5000);
|
|
791
|
+
|
|
792
|
+
return true;
|
|
229
793
|
};
|
|
230
794
|
|
|
795
|
+
/**
|
|
796
|
+
* 跳转到指定位置并高亮(外部调用接口)
|
|
797
|
+
* @param bbox 位置的边界框 [x1, y1, x2, y2]
|
|
798
|
+
* @param emitEvent 是否触发跳转事件,默认为 true
|
|
799
|
+
*/
|
|
800
|
+
const jumpToPosition = (
|
|
801
|
+
bbox: [number, number, number, number],
|
|
802
|
+
emitEvent: boolean = true
|
|
803
|
+
) => {
|
|
804
|
+
// 等待DOM更新后再高亮
|
|
805
|
+
nextTick(() => {
|
|
806
|
+
let retryCount = 0;
|
|
807
|
+
const maxRetries = 5;
|
|
808
|
+
const retryDelay = 200;
|
|
809
|
+
|
|
810
|
+
const tryHighlight = () => {
|
|
811
|
+
const success = highlightPosition(bbox, true);
|
|
812
|
+
if (success) {
|
|
813
|
+
// 高亮成功,触发事件
|
|
814
|
+
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);
|
|
819
|
+
}
|
|
820
|
+
} else if (retryCount < maxRetries) {
|
|
821
|
+
retryCount++;
|
|
822
|
+
setTimeout(tryHighlight, retryDelay);
|
|
823
|
+
} else {
|
|
824
|
+
console.warn(`无法找到并高亮指定位置: bbox: [${bbox.join(', ')}]`);
|
|
825
|
+
}
|
|
826
|
+
};
|
|
827
|
+
|
|
828
|
+
setTimeout(tryHighlight, 300);
|
|
829
|
+
});
|
|
830
|
+
};
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* 监听 blocksData 变化,重新渲染文本图层
|
|
834
|
+
*/
|
|
835
|
+
watch(
|
|
836
|
+
() => props.blocksData,
|
|
837
|
+
() => {
|
|
838
|
+
nextTick(() => {
|
|
839
|
+
renderTextLayer();
|
|
840
|
+
});
|
|
841
|
+
},
|
|
842
|
+
{ deep: true, immediate: false }
|
|
843
|
+
);
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* 监听图片尺寸变化,重新渲染文本图层
|
|
847
|
+
*/
|
|
848
|
+
watch(
|
|
849
|
+
() => imageSize.value,
|
|
850
|
+
() => {
|
|
851
|
+
if (imageSize.value.width > 0 && imageSize.value.height > 0) {
|
|
852
|
+
nextTick(() => {
|
|
853
|
+
renderTextLayer();
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
},
|
|
857
|
+
{ deep: true }
|
|
858
|
+
);
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* 监听缩放、旋转、位置变化,更新文本图层位置
|
|
862
|
+
*/
|
|
863
|
+
watch([scale, rotation, position], () => {
|
|
864
|
+
// 文本图层会跟随图片的 transform,不需要手动更新位置
|
|
865
|
+
// 但需要确保文本图层与图片同步变换
|
|
866
|
+
}, { deep: true });
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* 组件挂载时的初始化
|
|
870
|
+
*/
|
|
871
|
+
onMounted(() => {
|
|
872
|
+
// 如果图片已经加载完成,立即渲染文本图层
|
|
873
|
+
if (imageRef.value && imageRef.value.complete) {
|
|
874
|
+
nextTick(() => {
|
|
875
|
+
renderTextLayer();
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* 组件卸载前的清理
|
|
882
|
+
*/
|
|
883
|
+
onBeforeUnmount(() => {
|
|
884
|
+
if (hideTimer) {
|
|
885
|
+
clearTimeout(hideTimer);
|
|
886
|
+
hideTimer = null;
|
|
887
|
+
}
|
|
888
|
+
});
|
|
889
|
+
|
|
231
890
|
defineExpose({
|
|
232
|
-
reset
|
|
891
|
+
reset,
|
|
892
|
+
jumpToPosition // 暴露定位方法给父组件
|
|
233
893
|
});
|
|
234
894
|
</script>
|
|
235
895
|
|
|
236
896
|
<style lang="less" scoped>
|
|
237
897
|
// 样式已统一到公共样式文件,这里只保留组件特定样式
|
|
238
|
-
|
|
898
|
+
|
|
899
|
+
// 图片容器
|
|
900
|
+
.image-container {
|
|
239
901
|
flex: 1;
|
|
240
902
|
position: relative;
|
|
241
903
|
width: 100%;
|
|
242
904
|
height: 100%;
|
|
905
|
+
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
|
+
}
|
|
920
|
+
|
|
921
|
+
&::-webkit-scrollbar-track {
|
|
922
|
+
background-color: rgb(243 244 246);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// 图片包装器容器(用于居中显示和提供滚动空间)
|
|
927
|
+
.image-wrapper-container {
|
|
243
928
|
display: flex;
|
|
244
929
|
align-items: center;
|
|
245
930
|
justify-content: center;
|
|
246
|
-
|
|
931
|
+
width: 100%;
|
|
932
|
+
min-height: 100%;
|
|
933
|
+
padding: 20px; // 添加内边距,确保放大后的图片有滚动空间
|
|
934
|
+
box-sizing: border-box;
|
|
935
|
+
// 确保容器能够正确滚动
|
|
936
|
+
position: relative;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// 图片包装器(包含图片和文本图层)
|
|
940
|
+
.image-wrapper {
|
|
941
|
+
position: relative;
|
|
942
|
+
display: inline-block;
|
|
943
|
+
transform-origin: center center;
|
|
944
|
+
flex-shrink: 0;
|
|
945
|
+
// 确保包装器能够正确占据空间,支持滚动
|
|
946
|
+
max-width: none;
|
|
947
|
+
max-height: none;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// 文本图层样式
|
|
951
|
+
.text-layer {
|
|
952
|
+
position: absolute;
|
|
953
|
+
top: 0;
|
|
954
|
+
left: 0;
|
|
955
|
+
width: 100%;
|
|
956
|
+
height: 100%;
|
|
957
|
+
pointer-events: auto; // 允许鼠标事件
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// 复制按钮浮层
|
|
961
|
+
.copy-button-popup {
|
|
962
|
+
position: absolute;
|
|
963
|
+
z-index: 1000;
|
|
964
|
+
display: flex;
|
|
965
|
+
flex-direction: row;
|
|
966
|
+
width: fit-content;
|
|
967
|
+
padding: 4px;
|
|
968
|
+
pointer-events: auto;
|
|
969
|
+
background-color: #fff;
|
|
970
|
+
border-radius: 6px;
|
|
971
|
+
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
|
|
972
|
+
animation: fade-in 0.2s ease;
|
|
973
|
+
|
|
974
|
+
.copy-button-popup-action {
|
|
975
|
+
display: flex;
|
|
976
|
+
flex-direction: row;
|
|
977
|
+
align-items: center;
|
|
978
|
+
height: 30px;
|
|
979
|
+
padding: 0 6px;
|
|
980
|
+
font-size: 12px;
|
|
981
|
+
color: #1d2129;
|
|
982
|
+
white-space: nowrap;
|
|
983
|
+
cursor: pointer;
|
|
984
|
+
border-radius: 4px;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
.copy-button-popup-action:hover {
|
|
988
|
+
background-color: #f0f0f0;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// 淡入动画
|
|
993
|
+
@keyframes fade-in {
|
|
994
|
+
from {
|
|
995
|
+
opacity: 0;
|
|
996
|
+
transform: translateY(8px);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
to {
|
|
1000
|
+
opacity: 1;
|
|
1001
|
+
transform: translateY(0);
|
|
1002
|
+
}
|
|
247
1003
|
}
|
|
248
1004
|
</style>
|