@koi-br/ocr-web-sdk 1.0.0
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/README.md +754 -0
- package/dist/index.cjs.js +538 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.esm.js +85525 -0
- package/dist/preview/PdfPreview.vue.d.ts +2 -0
- package/dist/src/index.d.ts +11 -0
- package/dist/style.css +1 -0
- package/package.json +52 -0
- package/preview/ImagePreview.vue +235 -0
- package/preview/PdfPreview.vue +2571 -0
- package/preview/docxPreview.vue +216 -0
- package/preview/index.vue +317 -0
- package/preview/ofdPreview.vue +107 -0
- package/preview/tifPreview.vue +362 -0
- package/preview/xlsxPreview.vue +168 -0
|
@@ -0,0 +1,2571 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="preview-container">
|
|
3
|
+
<!-- 工具栏 -->
|
|
4
|
+
<div class="preview-toolbar preview-toolbar-between">
|
|
5
|
+
<!-- 左侧: 翻页控制 -->
|
|
6
|
+
<div class="toolbar-group">
|
|
7
|
+
<!-- 目录和印章列表切换按钮 -->
|
|
8
|
+
<a-tooltip
|
|
9
|
+
v-if="props.showTocSidebar !== false"
|
|
10
|
+
class="ai-chat-tooltip"
|
|
11
|
+
mini
|
|
12
|
+
position="bottom"
|
|
13
|
+
content="目录和印章"
|
|
14
|
+
>
|
|
15
|
+
<a-button
|
|
16
|
+
size="small"
|
|
17
|
+
type="outline"
|
|
18
|
+
@click="showTocSidebar = !showTocSidebar"
|
|
19
|
+
>
|
|
20
|
+
<List :size="16" />
|
|
21
|
+
</a-button>
|
|
22
|
+
</a-tooltip>
|
|
23
|
+
|
|
24
|
+
<div class="page-info">
|
|
25
|
+
<!-- 当前页码输入框(自定义实现) -->
|
|
26
|
+
<input
|
|
27
|
+
v-model="inputPage"
|
|
28
|
+
type="text"
|
|
29
|
+
class="page-input"
|
|
30
|
+
:style="{ width: pageInputWidth }"
|
|
31
|
+
@input="handlePageInputChange"
|
|
32
|
+
@blur="handlePageInputBlur"
|
|
33
|
+
@keydown.enter="handlePageInputEnter"
|
|
34
|
+
/>
|
|
35
|
+
<span class="page-separator">/</span>
|
|
36
|
+
<span class="total-pages">{{ totalPages }}</span>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<!-- 右侧: 缩放控制和全屏控制 -->
|
|
41
|
+
<div class="toolbar-group">
|
|
42
|
+
<!-- 缩放模式选择 -->
|
|
43
|
+
<a-dropdown trigger="hover" @select="handleScaleModeChange">
|
|
44
|
+
<a-button size="small" type="outline" class="scale-mode-btn">
|
|
45
|
+
<component :is="currentScaleModeIcon" :size="16" />
|
|
46
|
+
</a-button>
|
|
47
|
+
<template #content>
|
|
48
|
+
<a-doption
|
|
49
|
+
v-for="mode in scaleModeOptions"
|
|
50
|
+
:key="mode.value"
|
|
51
|
+
:value="mode.value"
|
|
52
|
+
class="scale-mode-option"
|
|
53
|
+
>
|
|
54
|
+
<div
|
|
55
|
+
class="scale-mode-item"
|
|
56
|
+
:class="{ 'scale-mode-item-active': scaleMode === mode.value }"
|
|
57
|
+
>
|
|
58
|
+
<component
|
|
59
|
+
:is="mode.icon"
|
|
60
|
+
:size="16"
|
|
61
|
+
class="scale-mode-item-icon"
|
|
62
|
+
/>
|
|
63
|
+
<span class="scale-mode-item-label">{{ mode.label }}</span>
|
|
64
|
+
<Check
|
|
65
|
+
v-if="scaleMode === mode.value"
|
|
66
|
+
:size="16"
|
|
67
|
+
class="scale-mode-item-check"
|
|
68
|
+
/>
|
|
69
|
+
</div>
|
|
70
|
+
</a-doption>
|
|
71
|
+
</template>
|
|
72
|
+
</a-dropdown>
|
|
73
|
+
|
|
74
|
+
<!-- 分隔线 -->
|
|
75
|
+
<div class="toolbar-divider"></div>
|
|
76
|
+
|
|
77
|
+
<!-- 重置按钮 -->
|
|
78
|
+
<a-tooltip mini position="bottom" content="重置">
|
|
79
|
+
<a-button size="small" type="outline" @click="reset">
|
|
80
|
+
<RefreshCcw :size="16" />
|
|
81
|
+
</a-button>
|
|
82
|
+
</a-tooltip>
|
|
83
|
+
|
|
84
|
+
<!-- 缩小按钮 -->
|
|
85
|
+
<a-tooltip mini position="bottom" content="缩小">
|
|
86
|
+
<a-button
|
|
87
|
+
size="small"
|
|
88
|
+
type="outline"
|
|
89
|
+
:disabled="scale <= 0.5"
|
|
90
|
+
@click="zoomOut"
|
|
91
|
+
>
|
|
92
|
+
<ZoomOut :size="16" />
|
|
93
|
+
</a-button>
|
|
94
|
+
</a-tooltip>
|
|
95
|
+
|
|
96
|
+
<!-- 放大按钮 -->
|
|
97
|
+
<a-tooltip mini position="bottom" content="放大">
|
|
98
|
+
<a-button
|
|
99
|
+
size="small"
|
|
100
|
+
type="outline"
|
|
101
|
+
:disabled="scale >= 3"
|
|
102
|
+
@click="zoomIn"
|
|
103
|
+
>
|
|
104
|
+
<ZoomIn :size="16" />
|
|
105
|
+
</a-button>
|
|
106
|
+
</a-tooltip>
|
|
107
|
+
|
|
108
|
+
<!-- 分隔线 -->
|
|
109
|
+
<div class="toolbar-divider"></div>
|
|
110
|
+
|
|
111
|
+
<!-- 下载按钮 -->
|
|
112
|
+
<a-tooltip
|
|
113
|
+
v-if="props.isDownload"
|
|
114
|
+
mini
|
|
115
|
+
position="bottom"
|
|
116
|
+
content="下载"
|
|
117
|
+
>
|
|
118
|
+
<a-button size="small" type="outline" @click="emit('download')">
|
|
119
|
+
<Download :size="16" />
|
|
120
|
+
</a-button>
|
|
121
|
+
</a-tooltip>
|
|
122
|
+
|
|
123
|
+
<!-- 分隔线(仅在显示下载按钮时显示) -->
|
|
124
|
+
<div v-if="props.isDownload" class="toolbar-divider"></div>
|
|
125
|
+
|
|
126
|
+
<!-- 全屏按钮 -->
|
|
127
|
+
<a-tooltip
|
|
128
|
+
mini
|
|
129
|
+
position="bottom"
|
|
130
|
+
:content="isFullscreen ? '退出全屏' : '全屏查看'"
|
|
131
|
+
>
|
|
132
|
+
<a-button size="small" type="outline" @click="toggleFullscreen">
|
|
133
|
+
<Maximize2 v-if="!isFullscreen" :size="15" />
|
|
134
|
+
<Minimize2 v-else :size="15" />
|
|
135
|
+
</a-button>
|
|
136
|
+
</a-tooltip>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
<!-- 主内容区域(包含侧边栏和PDF预览) -->
|
|
141
|
+
<div class="pdf-main-container">
|
|
142
|
+
<!-- 目录和印章侧边栏 -->
|
|
143
|
+
<div
|
|
144
|
+
v-if="props.showTocSidebar !== false"
|
|
145
|
+
class="toc-sidebar"
|
|
146
|
+
:class="{ 'toc-sidebar-show': showTocSidebar }"
|
|
147
|
+
>
|
|
148
|
+
<!-- Tab切换器 -->
|
|
149
|
+
<div class="toc-tab-switcher">
|
|
150
|
+
<div
|
|
151
|
+
class="toc-tab-item"
|
|
152
|
+
:class="{ 'toc-tab-item-active': activeTab === 'toc' }"
|
|
153
|
+
@click="activeTab = 'toc'"
|
|
154
|
+
>
|
|
155
|
+
<a-tooltip
|
|
156
|
+
class="ai-chat-tooltip"
|
|
157
|
+
mini
|
|
158
|
+
position="top"
|
|
159
|
+
content="目录"
|
|
160
|
+
>
|
|
161
|
+
<FileText :size="16" />
|
|
162
|
+
</a-tooltip>
|
|
163
|
+
</div>
|
|
164
|
+
<div
|
|
165
|
+
class="toc-tab-item"
|
|
166
|
+
:class="{ 'toc-tab-item-active': activeTab === 'seal' }"
|
|
167
|
+
@click="activeTab = 'seal'"
|
|
168
|
+
>
|
|
169
|
+
<a-tooltip
|
|
170
|
+
class="ai-chat-tooltip"
|
|
171
|
+
mini
|
|
172
|
+
position="top"
|
|
173
|
+
content="印章"
|
|
174
|
+
>
|
|
175
|
+
<Stamp :size="16" />
|
|
176
|
+
</a-tooltip>
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
<div class="toc-sidebar-content">
|
|
180
|
+
<!-- 目录tab内容 -->
|
|
181
|
+
<div v-if="activeTab === 'toc'" class="toc-tab-content">
|
|
182
|
+
<div v-if="tocList.length === 0" class="toc-empty">暂无目录</div>
|
|
183
|
+
<div
|
|
184
|
+
v-for="item in tocList"
|
|
185
|
+
:key="item.id"
|
|
186
|
+
class="toc-item"
|
|
187
|
+
:class="{
|
|
188
|
+
'toc-item-doc-title': item.type === 'doc_title',
|
|
189
|
+
'toc-item-paragraph': item.type === 'paragraph_title',
|
|
190
|
+
}"
|
|
191
|
+
@click="jumpToBlock(item)"
|
|
192
|
+
>
|
|
193
|
+
<span class="toc-item-content">{{ item.content }}</span>
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
<!-- 印章tab内容 -->
|
|
197
|
+
<div v-if="activeTab === 'seal'" class="toc-tab-content">
|
|
198
|
+
<div v-if="sealList.length === 0" class="toc-empty">暂无印章</div>
|
|
199
|
+
<div
|
|
200
|
+
v-for="seal in sealList"
|
|
201
|
+
:key="seal.id"
|
|
202
|
+
class="toc-item toc-item-seal"
|
|
203
|
+
@click="jumpToSeal(seal)"
|
|
204
|
+
>
|
|
205
|
+
<span class="toc-item-content">{{ seal.name }}</span>
|
|
206
|
+
<span class="toc-item-page">第{{ seal.page }}页</span>
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
<!-- PDF 内容区域 -->
|
|
213
|
+
<div
|
|
214
|
+
ref="containerRef"
|
|
215
|
+
class="pdf-content"
|
|
216
|
+
:class="{
|
|
217
|
+
fullscreen: isFullscreen,
|
|
218
|
+
'pdf-content-with-sidebar':
|
|
219
|
+
props.showTocSidebar !== false && showTocSidebar,
|
|
220
|
+
}"
|
|
221
|
+
@scroll="handleScroll"
|
|
222
|
+
>
|
|
223
|
+
<div class="pdf-viewer">
|
|
224
|
+
<!-- PDF 缩放包装器 -->
|
|
225
|
+
|
|
226
|
+
<div
|
|
227
|
+
class="pdf-scale-wrapper"
|
|
228
|
+
:style="{
|
|
229
|
+
transform: `scale(${scale})`,
|
|
230
|
+
transformOrigin: 'top center',
|
|
231
|
+
}"
|
|
232
|
+
>
|
|
233
|
+
<!-- 渲染所有 PDF 页面 -->
|
|
234
|
+
<div
|
|
235
|
+
v-for="pageNum in totalPages"
|
|
236
|
+
:key="pageNum"
|
|
237
|
+
class="pdf-page-container"
|
|
238
|
+
:data-page-number="pageNum"
|
|
239
|
+
>
|
|
240
|
+
<!-- PDF 画布 -->
|
|
241
|
+
<canvas
|
|
242
|
+
:ref="(el) => setCanvasRef(el, pageNum)"
|
|
243
|
+
class="pdf-canvas"
|
|
244
|
+
></canvas>
|
|
245
|
+
|
|
246
|
+
<!-- 文本图层(用于段落选择) -->
|
|
247
|
+
<div
|
|
248
|
+
:ref="(el) => setTextLayerRef(el, pageNum)"
|
|
249
|
+
class="text-layer"
|
|
250
|
+
></div>
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
<!-- 复制按钮浮层 -->
|
|
256
|
+
<div
|
|
257
|
+
v-if="showCopyButton"
|
|
258
|
+
ref="copyButtonRef"
|
|
259
|
+
class="copy-button-popup"
|
|
260
|
+
:style="copyButtonStyle"
|
|
261
|
+
@mouseenter="cancelHideCopyButton"
|
|
262
|
+
@mouseleave="hideCopyButtonAndHighlight"
|
|
263
|
+
>
|
|
264
|
+
<div class="copy-button-popup-action" @click="copySelectedText">
|
|
265
|
+
<Copy :size="12" style="margin-right: 4px" />
|
|
266
|
+
复制
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
</template>
|
|
273
|
+
|
|
274
|
+
<script setup lang="ts">
|
|
275
|
+
import {
|
|
276
|
+
ref,
|
|
277
|
+
computed,
|
|
278
|
+
onMounted,
|
|
279
|
+
onBeforeUnmount,
|
|
280
|
+
watch,
|
|
281
|
+
nextTick,
|
|
282
|
+
markRaw,
|
|
283
|
+
} from "vue";
|
|
284
|
+
import * as pdfjsLib from "pdfjs-dist";
|
|
285
|
+
// 导入图标组件(在模板中使用)
|
|
286
|
+
// @ts-ignore: 这些导入在 Vue 模板中使用
|
|
287
|
+
import {
|
|
288
|
+
ZoomIn,
|
|
289
|
+
ZoomOut,
|
|
290
|
+
Maximize2,
|
|
291
|
+
Minimize2,
|
|
292
|
+
Copy,
|
|
293
|
+
Check,
|
|
294
|
+
ScanEye,
|
|
295
|
+
ArrowUpDown,
|
|
296
|
+
MoveHorizontal,
|
|
297
|
+
Ratio,
|
|
298
|
+
List,
|
|
299
|
+
FileText,
|
|
300
|
+
Stamp,
|
|
301
|
+
Download,
|
|
302
|
+
RefreshCcw,
|
|
303
|
+
} from "lucide-vue-next";
|
|
304
|
+
import { Message } from "@arco-design/web-vue";
|
|
305
|
+
|
|
306
|
+
// 定义组件名称
|
|
307
|
+
defineOptions({
|
|
308
|
+
name: "NewPdfViewerBloack",
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// 设置 PDF.js worker 路径
|
|
312
|
+
// 注意: 需要将 worker 文件复制到 public 目录
|
|
313
|
+
pdfjsLib.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsLib.version}/pdf.worker.min.js`;
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* PDF分块数据结构
|
|
317
|
+
*/
|
|
318
|
+
interface BlockInfo {
|
|
319
|
+
blockLabel: string; // 块标签
|
|
320
|
+
blockContent: string; // 块内容
|
|
321
|
+
blockBbox: [number, number, number, number]; // 块的边界框 [x1, y1, x2, y2]
|
|
322
|
+
blockPage: number; // 块所属的页码(从1开始)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* 目录项数据结构
|
|
327
|
+
*/
|
|
328
|
+
interface TocItem {
|
|
329
|
+
id: string;
|
|
330
|
+
type: "doc_title" | "paragraph_title";
|
|
331
|
+
content: string;
|
|
332
|
+
page: number;
|
|
333
|
+
blockBbox: [number, number, number, number];
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* 印章项数据结构
|
|
338
|
+
*/
|
|
339
|
+
interface SealItem {
|
|
340
|
+
id: string;
|
|
341
|
+
name: string;
|
|
342
|
+
page: number;
|
|
343
|
+
index: number;
|
|
344
|
+
blockBbox: [number, number, number, number];
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* 组件属性定义
|
|
349
|
+
*/
|
|
350
|
+
interface Props {
|
|
351
|
+
pdfUrl: string; // PDF 文件的 URL
|
|
352
|
+
blocksData?: BlockInfo[]; // PDF分块数据(从后端layout-parsing接口获取)
|
|
353
|
+
showTocSidebar?: boolean; // 是否显示目录侧边栏(包括工具栏中的切换按钮),默认为 true
|
|
354
|
+
isDownload?: boolean; // 是否显示下载按钮,默认为 false
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// 使用 withDefaults 显式设置默认值,确保未传递 prop 时使用默认值
|
|
358
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
359
|
+
showTocSidebar: true, // 默认显示目录侧边栏
|
|
360
|
+
isDownload: false, // 默认不显示下载按钮
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* 定义组件事件
|
|
365
|
+
*/
|
|
366
|
+
const emit = defineEmits<{
|
|
367
|
+
"pdf-loaded": []; // PDF 加载并渲染完成事件
|
|
368
|
+
download: []; // 下载事件
|
|
369
|
+
"page-jump": [pageNum: number]; // 页面跳转事件,传递目标页码
|
|
370
|
+
"position-jump": [pageNum: number, bbox: [number, number, number, number], type: "block" | "seal"]; // 位置跳转事件,传递页码、边界框和类型
|
|
371
|
+
}>();
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* 响应式状态
|
|
375
|
+
*/
|
|
376
|
+
const containerRef = ref<HTMLElement>(); // 容器元素引用
|
|
377
|
+
const copyButtonRef = ref<HTMLElement>(); // 复制按钮引用
|
|
378
|
+
|
|
379
|
+
// 使用 Map 存储每页的画布和文本图层引用
|
|
380
|
+
const canvasRefs = new Map<number, HTMLCanvasElement>();
|
|
381
|
+
const textLayerRefs = new Map<number, HTMLElement>();
|
|
382
|
+
const inputPage = ref<string>("1"); // 页码输入框的值(字符串类型)
|
|
383
|
+
|
|
384
|
+
// 设置画布引用
|
|
385
|
+
const setCanvasRef = (el: any, pageNum: number) => {
|
|
386
|
+
if (el) {
|
|
387
|
+
canvasRefs.set(pageNum, el);
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
// 设置文本图层引用
|
|
392
|
+
const setTextLayerRef = (el: any, pageNum: number) => {
|
|
393
|
+
if (el) {
|
|
394
|
+
textLayerRefs.set(pageNum, el);
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
// PDF 文档相关状态
|
|
399
|
+
const pdfDoc = ref<any>(null); // PDF 文档对象
|
|
400
|
+
const currentPage = ref(1); // 当前页码(用于页面导航)
|
|
401
|
+
const totalPages = ref(0); // 总页数
|
|
402
|
+
const scale = ref(1); // 缩放比例
|
|
403
|
+
const scaleMode = ref("auto"); // 缩放模式:默认使用自动缩放
|
|
404
|
+
const isFullscreen = ref(false); // 是否全屏
|
|
405
|
+
|
|
406
|
+
// 文本选择相关状态
|
|
407
|
+
const showCopyButton = ref(false); // 是否显示复制按钮
|
|
408
|
+
const selectedText = ref(""); // 选中的文本
|
|
409
|
+
const copyButtonStyle = ref<any>({}); // 复制按钮的样式
|
|
410
|
+
const activeBlockDiv = ref<HTMLElement | null>(null); // 当前激活的文本块元素
|
|
411
|
+
const isTocHighlighted = ref(false); // 标识是否为目录点击的高亮(不应被鼠标事件清除)
|
|
412
|
+
const isSealHighlighted = ref(false); // 标识是否为印章点击的高亮(不应被鼠标事件清除)
|
|
413
|
+
|
|
414
|
+
// 目录和印章相关状态
|
|
415
|
+
const tocList = ref<TocItem[]>([]); // 目录列表
|
|
416
|
+
const sealList = ref<SealItem[]>([]); // 印章列表
|
|
417
|
+
const highlightedSealDiv = ref<HTMLElement | null>(null); // 当前高亮的印章元素
|
|
418
|
+
const showTocSidebar = ref(false); // 目录侧边栏显示状态
|
|
419
|
+
const activeTab = ref<"toc" | "seal">("toc"); // 当前选中的tab:目录或印章
|
|
420
|
+
|
|
421
|
+
// 已渲染文本图层的页面集合(用于增量渲染,避免闪烁)
|
|
422
|
+
const renderedPages = ref<Set<number>>(new Set());
|
|
423
|
+
|
|
424
|
+
// 防抖定时器(用于避免频繁触发渲染)
|
|
425
|
+
let renderDebounceTimer: any = null;
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* 缩放模式选项配置
|
|
429
|
+
*/
|
|
430
|
+
const scaleModeOptions = [
|
|
431
|
+
{ value: "auto", label: "自动缩放", icon: ScanEye },
|
|
432
|
+
{ value: "page-fit", label: "适合页高", icon: ArrowUpDown },
|
|
433
|
+
{ value: "page-width", label: "适合页宽", icon: MoveHorizontal },
|
|
434
|
+
{ value: "page-actual", label: "实际大小", icon: Ratio },
|
|
435
|
+
];
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* 计算当前缩放模式的图标
|
|
439
|
+
*/
|
|
440
|
+
const currentScaleModeIcon = computed(() => {
|
|
441
|
+
const currentMode = scaleModeOptions.find(
|
|
442
|
+
(mode) => mode.value === scaleMode.value
|
|
443
|
+
);
|
|
444
|
+
return currentMode?.icon || ScanEye;
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* 计算页码输入框的宽度
|
|
449
|
+
* 根据总页数的位数动态调整宽度,确保输入框大小合适
|
|
450
|
+
* 左右padding各6px
|
|
451
|
+
*/
|
|
452
|
+
const pageInputWidth = computed(() => {
|
|
453
|
+
if (totalPages.value === 0) return "28px"; // 默认宽度(无总页数时)
|
|
454
|
+
|
|
455
|
+
// 计算总页数的位数
|
|
456
|
+
const digits = totalPages.value.toString().length;
|
|
457
|
+
|
|
458
|
+
// 根据位数返回合适的宽度
|
|
459
|
+
// 每个数字大约需要 8px(字体13px,数字宽度约7-8px)
|
|
460
|
+
// 加上左右padding共12px(每边6px)
|
|
461
|
+
const width = digits * 8 + 12;
|
|
462
|
+
|
|
463
|
+
// 只设置最大宽度,不限制最小宽度,让计算公式自然生效
|
|
464
|
+
return `${Math.min(76, width)}px`;
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* 加载 PDF 文档
|
|
469
|
+
*/
|
|
470
|
+
const loadPDF = async () => {
|
|
471
|
+
try {
|
|
472
|
+
if (!props.pdfUrl) {
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// 重置已渲染页面集合(新PDF加载时清空)
|
|
477
|
+
renderedPages.value.clear();
|
|
478
|
+
|
|
479
|
+
// 清除防抖定时器
|
|
480
|
+
if (renderDebounceTimer) {
|
|
481
|
+
clearTimeout(renderDebounceTimer);
|
|
482
|
+
renderDebounceTimer = null;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// 配置 PDF.js 加载选项
|
|
486
|
+
const loadingTask = pdfjsLib.getDocument({
|
|
487
|
+
url: props.pdfUrl,
|
|
488
|
+
// 对于 blob URL,禁用范围请求
|
|
489
|
+
disableRange: props.pdfUrl.startsWith("blob:"),
|
|
490
|
+
// 对于 blob URL,禁用流式加载
|
|
491
|
+
disableStream: props.pdfUrl.startsWith("blob:"),
|
|
492
|
+
// 不验证 CORS
|
|
493
|
+
withCredentials: false,
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
const pdf = await loadingTask.promise;
|
|
497
|
+
|
|
498
|
+
// 使用 markRaw 避免 Vue 的响应式代理,防止访问 PDF.js 私有字段时出错
|
|
499
|
+
pdfDoc.value = markRaw(pdf);
|
|
500
|
+
totalPages.value = pdf.numPages;
|
|
501
|
+
|
|
502
|
+
// 等待 DOM 更新
|
|
503
|
+
await nextTick();
|
|
504
|
+
|
|
505
|
+
// 先渲染第一页,快速计算缩放,再渲染其余页面
|
|
506
|
+
// 这样可以避免先显示实际大小再切换到 auto 模式的问题
|
|
507
|
+
if (scaleMode.value === "auto" && totalPages.value > 0) {
|
|
508
|
+
// 1. 先渲染第一页(快速)
|
|
509
|
+
await renderPage(1);
|
|
510
|
+
|
|
511
|
+
// 2. 等待 DOM 更新,确保 canvas 元素已渲染
|
|
512
|
+
await nextTick();
|
|
513
|
+
|
|
514
|
+
// 3. 等待容器布局稳定后再计算缩放比例
|
|
515
|
+
// 注意:.pdf-content 有 transition: width 0.3s,需要等待过渡完成
|
|
516
|
+
// 使用双重 requestAnimationFrame 确保 DOM 已更新,然后等待 transition 完成
|
|
517
|
+
await new Promise<void>((resolve) => {
|
|
518
|
+
requestAnimationFrame(() => {
|
|
519
|
+
requestAnimationFrame(async () => {
|
|
520
|
+
await nextTick();
|
|
521
|
+
// 等待 transition 完成(0.3s)加上一些缓冲时间(50ms)
|
|
522
|
+
setTimeout(() => {
|
|
523
|
+
if (containerRef.value && canvasRefs.get(1)) {
|
|
524
|
+
handleScaleModeChange("auto");
|
|
525
|
+
}
|
|
526
|
+
resolve();
|
|
527
|
+
}, 350);
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
// 4. 再次等待 DOM 更新,确保所有 canvas 引用都已准备好
|
|
533
|
+
await nextTick();
|
|
534
|
+
|
|
535
|
+
// 5. 渲染剩余页面(此时 scale 已经是正确的值)
|
|
536
|
+
await renderRemainingPages();
|
|
537
|
+
} else {
|
|
538
|
+
// 非 auto 模式,直接渲染所有页面
|
|
539
|
+
await renderAllPages();
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// 渲染完成后,计算包装器的实际高度
|
|
543
|
+
await nextTick();
|
|
544
|
+
|
|
545
|
+
// 等待第一页的 canvas 真正渲染完成
|
|
546
|
+
await new Promise<void>((resolve) => {
|
|
547
|
+
requestAnimationFrame(() => {
|
|
548
|
+
requestAnimationFrame(() => {
|
|
549
|
+
// 确保第一页的 canvas 已经渲染
|
|
550
|
+
if (canvasRefs.get(1)) {
|
|
551
|
+
resolve();
|
|
552
|
+
} else {
|
|
553
|
+
// 如果还没有,再等待一下
|
|
554
|
+
setTimeout(resolve, 100);
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
// 发出 PDF 加载完成事件
|
|
561
|
+
emit("pdf-loaded");
|
|
562
|
+
} catch (error) {
|
|
563
|
+
Message.error("加载 PDF 文件失败,请查看控制台了解详情");
|
|
564
|
+
}
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* 渲染所有页面(已废弃,保留用于兼容)
|
|
569
|
+
* 建议使用 renderRemainingPages() 替代
|
|
570
|
+
*/
|
|
571
|
+
const renderAllPages = async () => {
|
|
572
|
+
if (!pdfDoc.value) {
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// 并行渲染所有页面以提高性能
|
|
577
|
+
const renderPromises: Promise<void>[] = [];
|
|
578
|
+
for (let pageNum = 1; pageNum <= totalPages.value; pageNum++) {
|
|
579
|
+
renderPromises.push(renderPage(pageNum));
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
await Promise.all(renderPromises);
|
|
583
|
+
|
|
584
|
+
// 渲染完成后,计算包装器的实际高度
|
|
585
|
+
await nextTick();
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* 渲染剩余页面(从第2页开始)
|
|
590
|
+
* 用于先渲染第一页计算缩放后,再渲染其他页面的场景
|
|
591
|
+
*/
|
|
592
|
+
const renderRemainingPages = async () => {
|
|
593
|
+
if (!pdfDoc.value || totalPages.value <= 1) {
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// 确保所有 canvas 引用都已准备好(等待 Vue 的 ref 系统完成)
|
|
598
|
+
await nextTick();
|
|
599
|
+
|
|
600
|
+
// 并行渲染剩余页面以提高性能(从第2页开始)
|
|
601
|
+
const renderPromises: Promise<void>[] = [];
|
|
602
|
+
for (let pageNum = 2; pageNum <= totalPages.value; pageNum++) {
|
|
603
|
+
renderPromises.push(renderPage(pageNum));
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
await Promise.all(renderPromises);
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* 渲染指定页面
|
|
611
|
+
* @param pageNum 页码
|
|
612
|
+
*/
|
|
613
|
+
const renderPage = async (pageNum: number) => {
|
|
614
|
+
if (!pdfDoc.value) {
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const canvas = canvasRefs.get(pageNum);
|
|
619
|
+
if (!canvas) {
|
|
620
|
+
console.warn(
|
|
621
|
+
`⚠️ 第 ${pageNum} 页的 canvas 引用不存在,跳过渲染。这可能是由于 Vue ref 系统尚未完成初始化。`
|
|
622
|
+
);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
try {
|
|
627
|
+
// 获取页面 - 使用更安全的方式
|
|
628
|
+
let page;
|
|
629
|
+
try {
|
|
630
|
+
page = await pdfDoc.value.getPage(pageNum);
|
|
631
|
+
} catch (pageError) {
|
|
632
|
+
throw pageError;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
let viewport;
|
|
636
|
+
try {
|
|
637
|
+
//使用设备像素比来提高渲染质量
|
|
638
|
+
// 在高分辨率屏幕(如 Retina)上,devicePixelRatio 通常为 2 或 3
|
|
639
|
+
// 这样可以确保 PDF 渲染足够清晰,不会模糊
|
|
640
|
+
const pixelRatio = window.devicePixelRatio || 1;
|
|
641
|
+
|
|
642
|
+
// 使用设备像素比作为渲染比例
|
|
643
|
+
// 例如:Retina 屏幕会使用 scale: 2.0 来渲染
|
|
644
|
+
viewport = page.getViewport({ scale: pixelRatio });
|
|
645
|
+
} catch (viewportError) {
|
|
646
|
+
throw viewportError;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// 设置画布尺寸
|
|
650
|
+
const context = canvas.getContext("2d");
|
|
651
|
+
if (!context) {
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// 设置画布的实际像素尺寸(使用高分辨率)
|
|
656
|
+
canvas.width = viewport.width;
|
|
657
|
+
canvas.height = viewport.height;
|
|
658
|
+
|
|
659
|
+
// 通过 CSS 将画布缩放回逻辑尺寸
|
|
660
|
+
// 这样既保证了高分辨率渲染,又保持了正确的显示大小
|
|
661
|
+
const pixelRatio = window.devicePixelRatio || 1;
|
|
662
|
+
canvas.style.width = `${viewport.width / pixelRatio}px`;
|
|
663
|
+
canvas.style.height = `${viewport.height / pixelRatio}px`;
|
|
664
|
+
|
|
665
|
+
// 渲染页面到画布
|
|
666
|
+
const renderContext = {
|
|
667
|
+
canvasContext: context,
|
|
668
|
+
viewport: viewport,
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
const renderTask = page.render(renderContext);
|
|
672
|
+
await renderTask.promise;
|
|
673
|
+
|
|
674
|
+
// 渲染文本图层(扫描件可能没有文本,所以失败不影响显示)
|
|
675
|
+
const textLayer = textLayerRefs.get(pageNum);
|
|
676
|
+
if (textLayer) {
|
|
677
|
+
try {
|
|
678
|
+
await renderTextLayer(page, viewport, pageNum);
|
|
679
|
+
} catch (textError) {
|
|
680
|
+
console.warn(`⚠️ 第 ${pageNum} 页文本图层渲染失败:`, textError);
|
|
681
|
+
}
|
|
682
|
+
} else {
|
|
683
|
+
console.warn(`⚠️ 第 ${pageNum} 页 textLayer 不存在`);
|
|
684
|
+
}
|
|
685
|
+
} catch (error) {
|
|
686
|
+
Message.error(`渲染第 ${pageNum} 页失败,请查看控制台了解详情`);
|
|
687
|
+
}
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* 渲染文本图层(使用后端返回的blockBbox数据)
|
|
692
|
+
* @param _page PDF 页面对象(暂时不使用)
|
|
693
|
+
* @param viewport 视口对象
|
|
694
|
+
* @param pageNum 页码
|
|
695
|
+
*/
|
|
696
|
+
const renderTextLayer = async (_page: any, viewport: any, pageNum: number) => {
|
|
697
|
+
const textLayer = textLayerRefs.get(pageNum);
|
|
698
|
+
const canvas = canvasRefs.get(pageNum);
|
|
699
|
+
|
|
700
|
+
if (!textLayer) {
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (!canvas) {
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// 如果没有提供分块数据,跳过渲染(不标记为已渲染)
|
|
709
|
+
if (!props.blocksData || props.blocksData.length === 0) {
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// 只渲染属于当前页面的块
|
|
714
|
+
const currentPageBlocks = props.blocksData.filter(
|
|
715
|
+
(block) => block.blockPage === pageNum
|
|
716
|
+
);
|
|
717
|
+
|
|
718
|
+
// 如果当前页面没有数据,跳过渲染(不标记为已渲染)
|
|
719
|
+
if (currentPageBlocks.length === 0) {
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// 如果页面已渲染且用户正在交互,跳过渲染(避免打断用户)
|
|
724
|
+
// 但如果页面已渲染但没有数据(之前渲染时没有数据),应该重新渲染
|
|
725
|
+
if (renderedPages.value.has(pageNum) && showCopyButton.value) {
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// 如果页面已渲染,但当前有数据,需要检查是否真的渲染过
|
|
730
|
+
// 如果textLayer是空的,说明之前没有真正渲染过,应该重新渲染
|
|
731
|
+
if (renderedPages.value.has(pageNum) && textLayer.children.length === 0) {
|
|
732
|
+
// 从已渲染集合中移除,允许重新渲染
|
|
733
|
+
renderedPages.value.delete(pageNum);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
try {
|
|
737
|
+
// 设置文本图层的尺寸与 Canvas 的 CSS 尺寸一致(逻辑尺寸,非物理像素)
|
|
738
|
+
// 因为 canvas 通过 CSS 缩放回了逻辑尺寸,文本图层也应该使用逻辑尺寸
|
|
739
|
+
const pixelRatio = window.devicePixelRatio || 1;
|
|
740
|
+
textLayer.style.width = `${canvas.width / pixelRatio}px`;
|
|
741
|
+
textLayer.style.height = `${canvas.height / pixelRatio}px`;
|
|
742
|
+
|
|
743
|
+
// 清空文本图层(如果用户不在交互,或者这是首次渲染)
|
|
744
|
+
textLayer.innerHTML = "";
|
|
745
|
+
|
|
746
|
+
// 如果清空前有激活的文本块,且该文本块属于当前页面,则清除引用
|
|
747
|
+
if (activeBlockDiv.value) {
|
|
748
|
+
const activePageElement = activeBlockDiv.value.closest(
|
|
749
|
+
".pdf-page-container"
|
|
750
|
+
);
|
|
751
|
+
const currentPageElement = textLayer.closest(".pdf-page-container");
|
|
752
|
+
if (activePageElement === currentPageElement) {
|
|
753
|
+
activeBlockDiv.value = null;
|
|
754
|
+
showCopyButton.value = false;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// 使用后端返回的blockBbox数据创建可交互的块
|
|
759
|
+
currentPageBlocks.forEach((block, index) => {
|
|
760
|
+
const { blockLabel, blockContent, blockBbox } = block;
|
|
761
|
+
|
|
762
|
+
// blockBbox 格式: [x1, y1, x2, y2]
|
|
763
|
+
// blockBbox 坐标通常是基于 PDF 原始尺寸的
|
|
764
|
+
// 由于我们使用了 pixelRatio 来渲染,坐标也需要相应缩放
|
|
765
|
+
const [x1, y1, x2, y2] = blockBbox;
|
|
766
|
+
const width = x2 - x1;
|
|
767
|
+
const height = y2 - y1;
|
|
768
|
+
|
|
769
|
+
// 创建文本块
|
|
770
|
+
const blockDiv = document.createElement("div");
|
|
771
|
+
blockDiv.className = "text-block";
|
|
772
|
+
blockDiv.dataset.text = blockContent;
|
|
773
|
+
blockDiv.dataset.label = blockLabel;
|
|
774
|
+
blockDiv.dataset.page = String(pageNum);
|
|
775
|
+
blockDiv.dataset.bbox = JSON.stringify(blockBbox);
|
|
776
|
+
|
|
777
|
+
// 设置基础样式
|
|
778
|
+
blockDiv.style.position = "absolute";
|
|
779
|
+
blockDiv.style.left = `${x1}px`;
|
|
780
|
+
blockDiv.style.top = `${y1}px`;
|
|
781
|
+
blockDiv.style.width = `${width}px`;
|
|
782
|
+
blockDiv.style.height = `${height}px`;
|
|
783
|
+
blockDiv.style.zIndex = "10";
|
|
784
|
+
blockDiv.style.cursor = "pointer";
|
|
785
|
+
blockDiv.style.borderRadius = "2px";
|
|
786
|
+
blockDiv.style.transition = "all 0.2s ease";
|
|
787
|
+
blockDiv.style.backgroundColor = "transparent";
|
|
788
|
+
// Hover 和点击事件
|
|
789
|
+
blockDiv.addEventListener("mouseenter", (e) => {
|
|
790
|
+
// 取消之前的隐藏定时器
|
|
791
|
+
if (hideTimer) {
|
|
792
|
+
clearTimeout(hideTimer);
|
|
793
|
+
hideTimer = null;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// 如果有之前激活的文本块,先恢复其样式
|
|
797
|
+
if (activeBlockDiv.value && activeBlockDiv.value !== blockDiv) {
|
|
798
|
+
// 如果之前是目录高亮,清除目录高亮标志
|
|
799
|
+
if (isTocHighlighted.value) {
|
|
800
|
+
isTocHighlighted.value = false;
|
|
801
|
+
}
|
|
802
|
+
activeBlockDiv.value.style.backgroundColor = "transparent";
|
|
803
|
+
activeBlockDiv.value.style.boxShadow = "none";
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// 清除印章高亮(鼠标悬停到文本块时)
|
|
807
|
+
if (highlightedSealDiv.value && !isSealHighlighted.value) {
|
|
808
|
+
highlightedSealDiv.value.style.backgroundColor = "transparent";
|
|
809
|
+
highlightedSealDiv.value.style.boxShadow = "none";
|
|
810
|
+
highlightedSealDiv.value = null;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// 设置当前文本块为激活状态
|
|
814
|
+
activeBlockDiv.value = blockDiv;
|
|
815
|
+
isTocHighlighted.value = false; // 鼠标悬停时清除目录高亮标志
|
|
816
|
+
isSealHighlighted.value = false; // 鼠标悬停时清除印章高亮标志
|
|
817
|
+
|
|
818
|
+
// 直接设置 hover 样式(解决 Vue scoped 样式问题)
|
|
819
|
+
blockDiv.style.backgroundColor =
|
|
820
|
+
"var(--s-color-brand-primary-transparent-3, rgba(0, 102, 255, .15))";
|
|
821
|
+
blockDiv.style.boxShadow = "0 0 0 2px rgba(30, 144, 255, 0.6)";
|
|
822
|
+
|
|
823
|
+
// 显示复制按钮
|
|
824
|
+
showCopyButtonForBlock(e, blockDiv);
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
blockDiv.addEventListener("mouseleave", () => {
|
|
828
|
+
// 延迟隐藏,给用户时间移动到复制按钮
|
|
829
|
+
hideCopyButtonAndHighlight();
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
textLayer.appendChild(blockDiv);
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
// 标记该页面已渲染
|
|
836
|
+
renderedPages.value.add(pageNum);
|
|
837
|
+
} catch (error) {
|
|
838
|
+
console.error("❌ 文本图层渲染失败:", error);
|
|
839
|
+
}
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* 显示文本块的复制按钮
|
|
844
|
+
*/
|
|
845
|
+
const showCopyButtonForBlock = (_event: MouseEvent, blockDiv: HTMLElement) => {
|
|
846
|
+
const text = blockDiv.dataset.text || "";
|
|
847
|
+
if (!text) return;
|
|
848
|
+
|
|
849
|
+
selectedText.value = text;
|
|
850
|
+
|
|
851
|
+
const rect = blockDiv.getBoundingClientRect();
|
|
852
|
+
const containerRect = containerRef.value?.getBoundingClientRect();
|
|
853
|
+
|
|
854
|
+
if (!containerRect || !containerRef.value) return;
|
|
855
|
+
|
|
856
|
+
// 计算复制按钮的位置(在块的上方或下方,横向排列)
|
|
857
|
+
// 根据按钮数量计算弹出框宽度
|
|
858
|
+
// 基础按钮:复制(总是显示)
|
|
859
|
+
const buttonCount = 1; // 复制按钮总是显示
|
|
860
|
+
const buttonWidth = 80; // 每个按钮约80px宽度(包含padding和间距)
|
|
861
|
+
const popupWidth = buttonCount * buttonWidth + 8; // 总宽度 = 按钮宽度 * 数量 + padding
|
|
862
|
+
const popupHeight = 38; // 高度 = 按钮高度30px + padding 8px
|
|
863
|
+
const spacing = 8; // 弹出框与文本块的间距
|
|
864
|
+
|
|
865
|
+
// 计算相对于容器的坐标(考虑滚动)
|
|
866
|
+
const relativeX =
|
|
867
|
+
rect.left - containerRect.left + containerRef.value.scrollLeft;
|
|
868
|
+
const relativeY = rect.top - containerRect.top + containerRef.value.scrollTop;
|
|
869
|
+
|
|
870
|
+
// 容器可用空间
|
|
871
|
+
const containerWidth = containerRef.value.clientWidth;
|
|
872
|
+
|
|
873
|
+
// 参考 ContextMenu 的边界处理逻辑:
|
|
874
|
+
// 如果下方空间不足,显示在上方;水平方向居中显示
|
|
875
|
+
// 使用视口坐标计算可用空间
|
|
876
|
+
const rectBottom = rect.bottom; // 文本块底部在视口中的位置
|
|
877
|
+
const rectTop = rect.top; // 文本块顶部在视口中的位置
|
|
878
|
+
const containerBottom = containerRect.bottom; // 容器底部在视口中的位置
|
|
879
|
+
const containerTop = containerRect.top; // 容器顶部在视口中的位置
|
|
880
|
+
|
|
881
|
+
// 计算下方可用空间(视口坐标)
|
|
882
|
+
const bottomSpace = containerBottom - rectBottom;
|
|
883
|
+
// 计算上方可用空间(视口坐标)
|
|
884
|
+
const topSpace = rectTop - containerTop;
|
|
885
|
+
|
|
886
|
+
// 优先显示在下方,如果下方空间不足(小于弹出框高度+间距)且上方空间更大,则显示在上方
|
|
887
|
+
const showOnBottom =
|
|
888
|
+
bottomSpace >= popupHeight + spacing ||
|
|
889
|
+
(bottomSpace > 0 && bottomSpace >= topSpace);
|
|
890
|
+
|
|
891
|
+
// 计算最终位置(相对于容器,考虑滚动)
|
|
892
|
+
let left: number;
|
|
893
|
+
let top: number;
|
|
894
|
+
|
|
895
|
+
// 水平方向:在文本块上方居中
|
|
896
|
+
const centerX = relativeX + rect.width / 2;
|
|
897
|
+
left = centerX - popupWidth / 2;
|
|
898
|
+
|
|
899
|
+
// 垂直方向:优先显示在下方,空间不足则显示在上方
|
|
900
|
+
if (showOnBottom) {
|
|
901
|
+
// 显示在下方
|
|
902
|
+
top = relativeY + rect.height + spacing;
|
|
903
|
+
} else {
|
|
904
|
+
// 显示在上方
|
|
905
|
+
top = relativeY - popupHeight - spacing;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// 二次检查:确保弹出框在容器可见区域内
|
|
909
|
+
// 计算弹出框顶部在视口中的位置
|
|
910
|
+
const popupTopInViewport =
|
|
911
|
+
top - containerRef.value.scrollTop + containerRect.top;
|
|
912
|
+
const popupBottomInViewport = popupTopInViewport + popupHeight;
|
|
913
|
+
|
|
914
|
+
// 如果超出下边界,改为显示在上方
|
|
915
|
+
if (popupBottomInViewport > containerBottom - spacing) {
|
|
916
|
+
top = relativeY - popupHeight - spacing;
|
|
917
|
+
}
|
|
918
|
+
// 如果超出上边界,改为显示在下方
|
|
919
|
+
const newPopupTopInViewport =
|
|
920
|
+
top - containerRef.value.scrollTop + containerRect.top;
|
|
921
|
+
if (newPopupTopInViewport < containerTop + spacing) {
|
|
922
|
+
top = relativeY + rect.height + spacing;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// 确保不超出容器边界(参考 ContextMenu 的处理方式)
|
|
926
|
+
// 如果超出右边界,向左调整
|
|
927
|
+
if (left + popupWidth > containerWidth - spacing) {
|
|
928
|
+
left = containerWidth - popupWidth - spacing;
|
|
929
|
+
}
|
|
930
|
+
// 如果超出左边界,向右调整
|
|
931
|
+
if (left < spacing) {
|
|
932
|
+
left = spacing;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
copyButtonStyle.value = {
|
|
936
|
+
left: `${left}px`,
|
|
937
|
+
top: `${top}px`,
|
|
938
|
+
};
|
|
939
|
+
|
|
940
|
+
showCopyButton.value = true;
|
|
941
|
+
};
|
|
942
|
+
|
|
943
|
+
// 隐藏定时器
|
|
944
|
+
let hideTimer: any = null;
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* 隐藏复制按钮并移除文本块高亮
|
|
948
|
+
* 延迟执行,给用户时间将鼠标移到复制按钮上
|
|
949
|
+
*/
|
|
950
|
+
const hideCopyButtonAndHighlight = () => {
|
|
951
|
+
hideTimer = setTimeout(() => {
|
|
952
|
+
// 隐藏复制按钮
|
|
953
|
+
showCopyButton.value = false;
|
|
954
|
+
|
|
955
|
+
// 移除文本块高亮(但保留目录点击的高亮)
|
|
956
|
+
if (activeBlockDiv.value && !isTocHighlighted.value) {
|
|
957
|
+
activeBlockDiv.value.style.backgroundColor = "transparent";
|
|
958
|
+
activeBlockDiv.value.style.boxShadow = "none";
|
|
959
|
+
activeBlockDiv.value = null;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// 移除印章高亮(但保留印章点击的高亮)
|
|
963
|
+
if (highlightedSealDiv.value && !isSealHighlighted.value) {
|
|
964
|
+
highlightedSealDiv.value.style.backgroundColor = "transparent";
|
|
965
|
+
highlightedSealDiv.value.style.boxShadow = "none";
|
|
966
|
+
highlightedSealDiv.value = null;
|
|
967
|
+
}
|
|
968
|
+
}, 300);
|
|
969
|
+
};
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* 取消隐藏(鼠标进入复制按钮时调用)
|
|
973
|
+
* 保持复制按钮显示和文本块高亮状态
|
|
974
|
+
*/
|
|
975
|
+
const cancelHideCopyButton = () => {
|
|
976
|
+
if (hideTimer) {
|
|
977
|
+
clearTimeout(hideTimer);
|
|
978
|
+
hideTimer = null;
|
|
979
|
+
}
|
|
980
|
+
};
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* 将HTML表格转换为纯文本格式
|
|
984
|
+
* @param htmlString HTML字符串
|
|
985
|
+
* @returns 转换后的纯文本表格
|
|
986
|
+
*/
|
|
987
|
+
const convertHtmlTableToText = (htmlString: string): string => {
|
|
988
|
+
// 检查是否包含HTML标签
|
|
989
|
+
if (!/<[^>]+>/g.test(htmlString)) {
|
|
990
|
+
return htmlString; // 不是HTML,直接返回
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
try {
|
|
994
|
+
// 创建一个临时DOM元素来解析HTML
|
|
995
|
+
const tempDiv = document.createElement("div");
|
|
996
|
+
tempDiv.innerHTML = htmlString;
|
|
997
|
+
|
|
998
|
+
// 查找表格元素
|
|
999
|
+
const tables = tempDiv.querySelectorAll("table");
|
|
1000
|
+
|
|
1001
|
+
if (tables.length === 0) {
|
|
1002
|
+
// 没有表格,返回纯文本内容
|
|
1003
|
+
return tempDiv.textContent || htmlString;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
let result = "";
|
|
1007
|
+
|
|
1008
|
+
// 处理每个表格
|
|
1009
|
+
tables.forEach((table, tableIndex) => {
|
|
1010
|
+
if (tableIndex > 0) {
|
|
1011
|
+
result += "\n\n"; // 多个表格之间添加空行
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
const rows = table.querySelectorAll("tr");
|
|
1015
|
+
|
|
1016
|
+
rows.forEach((row) => {
|
|
1017
|
+
const cells = row.querySelectorAll("td, th");
|
|
1018
|
+
const cellTexts: string[] = [];
|
|
1019
|
+
|
|
1020
|
+
cells.forEach((cell) => {
|
|
1021
|
+
// 获取单元格文本,去除首尾空白
|
|
1022
|
+
const text = (cell.textContent || "").trim();
|
|
1023
|
+
cellTexts.push(text);
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
// 将单元格用 " | " 连接,并在首尾也添加 " | "
|
|
1027
|
+
if (cellTexts.length > 0) {
|
|
1028
|
+
result += `| ${cellTexts.join(" | ")} |\n`;
|
|
1029
|
+
}
|
|
1030
|
+
});
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
return result.trim();
|
|
1034
|
+
} catch (error) {
|
|
1035
|
+
console.error("HTML表格转换失败:", error);
|
|
1036
|
+
// 转换失败时,返回去除HTML标签的纯文本
|
|
1037
|
+
return htmlString.replace(/<[^>]+>/g, "");
|
|
1038
|
+
}
|
|
1039
|
+
};
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* 复制选中的文本
|
|
1043
|
+
* 支持两种复制方式:
|
|
1044
|
+
* 1. 现代 Clipboard API(需要 HTTPS 或 localhost)
|
|
1045
|
+
* 2. 传统 execCommand 方法(兼容 HTTP 环境)
|
|
1046
|
+
*/
|
|
1047
|
+
const copySelectedText = async () => {
|
|
1048
|
+
try {
|
|
1049
|
+
// 将HTML表格转换为纯文本格式
|
|
1050
|
+
const textToCopy = convertHtmlTableToText(selectedText.value);
|
|
1051
|
+
|
|
1052
|
+
// 检查是否支持现代 Clipboard API
|
|
1053
|
+
if (
|
|
1054
|
+
navigator.clipboard &&
|
|
1055
|
+
typeof navigator.clipboard.writeText === "function"
|
|
1056
|
+
) {
|
|
1057
|
+
// ✅ 方法 1:使用现代 Clipboard API(安全上下文:HTTPS 或 localhost)
|
|
1058
|
+
try {
|
|
1059
|
+
await navigator.clipboard.writeText(textToCopy);
|
|
1060
|
+
} catch (clipboardError) {
|
|
1061
|
+
// 如果 Clipboard API 失败,降级到传统方法
|
|
1062
|
+
console.warn("Clipboard API 失败,使用降级方案:", clipboardError);
|
|
1063
|
+
fallbackCopyText(textToCopy);
|
|
1064
|
+
}
|
|
1065
|
+
} else {
|
|
1066
|
+
// ✅ 方法 2:使用传统的 execCommand 方法(兼容所有环境)
|
|
1067
|
+
fallbackCopyText(textToCopy);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// 隐藏复制按钮
|
|
1071
|
+
showCopyButton.value = false;
|
|
1072
|
+
|
|
1073
|
+
// 移除文本块高亮(但保留目录点击的高亮)
|
|
1074
|
+
if (activeBlockDiv.value && !isTocHighlighted.value) {
|
|
1075
|
+
activeBlockDiv.value.style.backgroundColor = "transparent";
|
|
1076
|
+
activeBlockDiv.value.style.boxShadow = "none";
|
|
1077
|
+
activeBlockDiv.value = null;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// 移除印章高亮(但保留印章点击的高亮)
|
|
1081
|
+
if (highlightedSealDiv.value && !isSealHighlighted.value) {
|
|
1082
|
+
highlightedSealDiv.value.style.backgroundColor = "transparent";
|
|
1083
|
+
highlightedSealDiv.value.style.boxShadow = "none";
|
|
1084
|
+
highlightedSealDiv.value = null;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// 清除选中状态
|
|
1088
|
+
window.getSelection()?.removeAllRanges();
|
|
1089
|
+
} catch (error) {
|
|
1090
|
+
console.error("复制失败:", error);
|
|
1091
|
+
Message.error("复制失败,请手动选择并复制文本");
|
|
1092
|
+
}
|
|
1093
|
+
};
|
|
1094
|
+
|
|
1095
|
+
/**
|
|
1096
|
+
* 降级复制方案:使用传统的 execCommand 方法
|
|
1097
|
+
* 适用于不支持 Clipboard API 的环境(如 HTTP、旧浏览器等)
|
|
1098
|
+
* @param text 要复制的文本内容
|
|
1099
|
+
*/
|
|
1100
|
+
const fallbackCopyText = (text: string) => {
|
|
1101
|
+
// 创建一个临时的 textarea 元素
|
|
1102
|
+
const textarea = document.createElement("textarea");
|
|
1103
|
+
|
|
1104
|
+
// 设置要复制的文本
|
|
1105
|
+
textarea.value = text;
|
|
1106
|
+
|
|
1107
|
+
// 设置样式,使其不可见且不影响页面布局
|
|
1108
|
+
textarea.style.position = "fixed"; // 固定定位,不占据文档流
|
|
1109
|
+
textarea.style.top = "-9999px"; // 移到屏幕外
|
|
1110
|
+
textarea.style.left = "-9999px";
|
|
1111
|
+
textarea.style.opacity = "0"; // 完全透明
|
|
1112
|
+
textarea.style.pointerEvents = "none"; // 不响应鼠标事件
|
|
1113
|
+
|
|
1114
|
+
// 添加到文档中(必须在 DOM 中才能选中)
|
|
1115
|
+
document.body.appendChild(textarea);
|
|
1116
|
+
|
|
1117
|
+
try {
|
|
1118
|
+
// 聚焦并选中文本
|
|
1119
|
+
textarea.focus();
|
|
1120
|
+
textarea.select();
|
|
1121
|
+
|
|
1122
|
+
// 兼容 iOS 设备
|
|
1123
|
+
textarea.setSelectionRange(0, textarea.value.length);
|
|
1124
|
+
|
|
1125
|
+
// 执行复制命令
|
|
1126
|
+
const successful = document.execCommand("copy");
|
|
1127
|
+
|
|
1128
|
+
if (!successful) {
|
|
1129
|
+
throw new Error('execCommand("copy") 返回 false');
|
|
1130
|
+
}
|
|
1131
|
+
} finally {
|
|
1132
|
+
// 无论成功或失败,都要移除临时元素
|
|
1133
|
+
document.body.removeChild(textarea);
|
|
1134
|
+
}
|
|
1135
|
+
};
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* 放大
|
|
1140
|
+
*/
|
|
1141
|
+
const zoomIn = () => {
|
|
1142
|
+
if (scale.value < 3) {
|
|
1143
|
+
scale.value = Math.min(scale.value + 0.25, 3);
|
|
1144
|
+
// 不再自动切换到自定义模式,保持当前的缩放模式不变
|
|
1145
|
+
}
|
|
1146
|
+
};
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* 缩小
|
|
1150
|
+
*/
|
|
1151
|
+
const zoomOut = () => {
|
|
1152
|
+
if (scale.value > 0.5) {
|
|
1153
|
+
scale.value = Math.max(scale.value - 0.25, 0.5);
|
|
1154
|
+
// 不再自动切换到自定义模式,保持当前的缩放模式不变
|
|
1155
|
+
}
|
|
1156
|
+
};
|
|
1157
|
+
|
|
1158
|
+
/**
|
|
1159
|
+
* 重置预览状态
|
|
1160
|
+
* 重置缩放、页码、滚动位置等
|
|
1161
|
+
*/
|
|
1162
|
+
const reset = () => {
|
|
1163
|
+
// 重置缩放比例
|
|
1164
|
+
scale.value = 1;
|
|
1165
|
+
|
|
1166
|
+
// 重置缩放模式为自动
|
|
1167
|
+
scaleMode.value = "auto";
|
|
1168
|
+
|
|
1169
|
+
// 重置到第一页
|
|
1170
|
+
currentPage.value = 1;
|
|
1171
|
+
inputPage.value = "1";
|
|
1172
|
+
|
|
1173
|
+
// 重置滚动位置到顶部
|
|
1174
|
+
if (containerRef.value) {
|
|
1175
|
+
containerRef.value.scrollTo({ top: 0, left: 0, behavior: "smooth" });
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// 重置文本选择状态
|
|
1179
|
+
showCopyButton.value = false;
|
|
1180
|
+
selectedText.value = "";
|
|
1181
|
+
activeBlockDiv.value = null;
|
|
1182
|
+
|
|
1183
|
+
// 等待滚动完成后,重新应用自动缩放模式
|
|
1184
|
+
nextTick(() => {
|
|
1185
|
+
if (scaleMode.value === "auto") {
|
|
1186
|
+
handleScaleModeChange("auto");
|
|
1187
|
+
}
|
|
1188
|
+
});
|
|
1189
|
+
};
|
|
1190
|
+
|
|
1191
|
+
/**
|
|
1192
|
+
* 处理缩放模式变化
|
|
1193
|
+
* @param mode 缩放模式
|
|
1194
|
+
*/
|
|
1195
|
+
const handleScaleModeChange = (mode: string) => {
|
|
1196
|
+
// 首先更新缩放模式状态,确保UI(图标和高亮)能正确更新
|
|
1197
|
+
scaleMode.value = mode;
|
|
1198
|
+
|
|
1199
|
+
// 使用第一页的画布作为参考
|
|
1200
|
+
const firstCanvas = canvasRefs.get(1);
|
|
1201
|
+
if (!containerRef.value || !firstCanvas) return;
|
|
1202
|
+
|
|
1203
|
+
// 获取实际的 .pdf-viewer 元素宽度,而不是 .pdf-content 的宽度
|
|
1204
|
+
// 因为 .pdf-viewer 有 padding: 0 5px,需要获取其实际内容宽度
|
|
1205
|
+
const pdfViewerElement = containerRef.value.querySelector(
|
|
1206
|
+
".pdf-viewer"
|
|
1207
|
+
) as HTMLElement;
|
|
1208
|
+
|
|
1209
|
+
// 获取容器尺寸
|
|
1210
|
+
let containerWidth: number;
|
|
1211
|
+
let containerHeight = containerRef.value.clientHeight;
|
|
1212
|
+
|
|
1213
|
+
if (pdfViewerElement) {
|
|
1214
|
+
// 使用 .pdf-viewer 的内容宽度(clientWidth 已经减去了 padding)
|
|
1215
|
+
containerWidth = pdfViewerElement.clientWidth;
|
|
1216
|
+
} else {
|
|
1217
|
+
// 降级方案:使用 containerRef 的宽度,但减去 .pdf-viewer 的 padding(10px)
|
|
1218
|
+
containerWidth = containerRef.value.clientWidth - 10;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// 如果容器尺寸无效(为0或过小),说明布局还没稳定
|
|
1222
|
+
// 使用 requestAnimationFrame 等待布局稳定后再计算
|
|
1223
|
+
// 同时检查尺寸是否合理(宽度应该至少大于100px)
|
|
1224
|
+
if (containerWidth === 0 || containerHeight === 0 || containerWidth < 100) {
|
|
1225
|
+
requestAnimationFrame(() => {
|
|
1226
|
+
requestAnimationFrame(() => {
|
|
1227
|
+
// 重新获取容器尺寸
|
|
1228
|
+
if (containerRef.value && canvasRefs.get(1)) {
|
|
1229
|
+
const pdfViewer = containerRef.value.querySelector(
|
|
1230
|
+
".pdf-viewer"
|
|
1231
|
+
) as HTMLElement;
|
|
1232
|
+
const validWidth = pdfViewer
|
|
1233
|
+
? pdfViewer.clientWidth
|
|
1234
|
+
: containerRef.value.clientWidth - 10;
|
|
1235
|
+
const validHeight = containerRef.value.clientHeight;
|
|
1236
|
+
if (validWidth > 100 && validHeight > 0) {
|
|
1237
|
+
// 使用有效的尺寸重新计算
|
|
1238
|
+
calculateScaleWithDimensions(mode, validWidth, validHeight);
|
|
1239
|
+
} else {
|
|
1240
|
+
// 如果还是无效,再延迟一次(给布局更多时间)
|
|
1241
|
+
setTimeout(() => {
|
|
1242
|
+
if (containerRef.value && canvasRefs.get(1)) {
|
|
1243
|
+
const pdfViewer2 = containerRef.value.querySelector(
|
|
1244
|
+
".pdf-viewer"
|
|
1245
|
+
) as HTMLElement;
|
|
1246
|
+
const finalWidth = pdfViewer2
|
|
1247
|
+
? pdfViewer2.clientWidth
|
|
1248
|
+
: containerRef.value.clientWidth - 10;
|
|
1249
|
+
const finalHeight = containerRef.value.clientHeight;
|
|
1250
|
+
if (finalWidth > 100 && finalHeight > 0) {
|
|
1251
|
+
calculateScaleWithDimensions(mode, finalWidth, finalHeight);
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
}, 100);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
});
|
|
1258
|
+
});
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// 容器尺寸有效,直接计算
|
|
1263
|
+
calculateScaleWithDimensions(mode, containerWidth, containerHeight);
|
|
1264
|
+
};
|
|
1265
|
+
|
|
1266
|
+
/**
|
|
1267
|
+
* 使用给定的容器尺寸计算缩放比例
|
|
1268
|
+
* @param mode 缩放模式
|
|
1269
|
+
* @param containerWidth 容器宽度
|
|
1270
|
+
* @param containerHeight 容器高度
|
|
1271
|
+
*/
|
|
1272
|
+
const calculateScaleWithDimensions = (
|
|
1273
|
+
mode: string,
|
|
1274
|
+
containerWidth: number,
|
|
1275
|
+
containerHeight: number
|
|
1276
|
+
) => {
|
|
1277
|
+
const firstCanvas = canvasRefs.get(1);
|
|
1278
|
+
if (!firstCanvas) return;
|
|
1279
|
+
|
|
1280
|
+
// 使用逻辑尺寸而不是物理像素尺寸
|
|
1281
|
+
// canvas.width 是物理像素(例如 Retina 上是 1200px)
|
|
1282
|
+
// 需要除以 pixelRatio 得到逻辑尺寸(例如 600px)
|
|
1283
|
+
const pixelRatio = window.devicePixelRatio || 1;
|
|
1284
|
+
const canvasWidth = firstCanvas.width / pixelRatio;
|
|
1285
|
+
const canvasHeight = firstCanvas.height / pixelRatio;
|
|
1286
|
+
|
|
1287
|
+
// 预留较小的边距,让PDF占用更多宽度
|
|
1288
|
+
// 注意:containerWidth 已经是 .pdf-viewer 的 clientWidth(已减去 padding)
|
|
1289
|
+
const horizontalMargin = 40; // 左右总共预留 40px 边距
|
|
1290
|
+
|
|
1291
|
+
// 计算可用宽度:直接使用容器宽度减去额外边距
|
|
1292
|
+
const availableWidth = containerWidth - horizontalMargin;
|
|
1293
|
+
|
|
1294
|
+
switch (mode) {
|
|
1295
|
+
case "auto":
|
|
1296
|
+
// 适合页宽: 适应宽度
|
|
1297
|
+
scale.value = availableWidth / canvasWidth;
|
|
1298
|
+
break;
|
|
1299
|
+
case "page-actual":
|
|
1300
|
+
// 实际大小: 100%
|
|
1301
|
+
scale.value = 1;
|
|
1302
|
+
break;
|
|
1303
|
+
case "page-fit":
|
|
1304
|
+
// 适合页高模式 - 让单页完整占满容器高度
|
|
1305
|
+
// .pdf-viewer 没有上下 padding,直接使用容器完整高度
|
|
1306
|
+
// 只有左右 padding: 0 5px 和页面间距 gap: 10px
|
|
1307
|
+
scale.value = containerHeight / canvasHeight;
|
|
1308
|
+
|
|
1309
|
+
nextTick(() => {
|
|
1310
|
+
if (containerRef.value) {
|
|
1311
|
+
containerRef.value.scrollTop = 0;
|
|
1312
|
+
}
|
|
1313
|
+
});
|
|
1314
|
+
break;
|
|
1315
|
+
case "page-width":
|
|
1316
|
+
// 适合页宽: 适应宽度
|
|
1317
|
+
scale.value = availableWidth / canvasWidth;
|
|
1318
|
+
break;
|
|
1319
|
+
}
|
|
1320
|
+
};
|
|
1321
|
+
|
|
1322
|
+
/**
|
|
1323
|
+
* 切换全屏
|
|
1324
|
+
*/
|
|
1325
|
+
const toggleFullscreen = () => {
|
|
1326
|
+
if (!containerRef.value) return;
|
|
1327
|
+
|
|
1328
|
+
if (!isFullscreen.value) {
|
|
1329
|
+
// 进入全屏
|
|
1330
|
+
if (containerRef.value.requestFullscreen) {
|
|
1331
|
+
containerRef.value.requestFullscreen();
|
|
1332
|
+
}
|
|
1333
|
+
} else {
|
|
1334
|
+
// 退出全屏
|
|
1335
|
+
if (document.exitFullscreen) {
|
|
1336
|
+
document.exitFullscreen();
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
};
|
|
1340
|
+
|
|
1341
|
+
/**
|
|
1342
|
+
* 监听全屏状态变化
|
|
1343
|
+
*/
|
|
1344
|
+
const handleFullscreenChange = () => {
|
|
1345
|
+
isFullscreen.value = !!document.fullscreenElement;
|
|
1346
|
+
};
|
|
1347
|
+
|
|
1348
|
+
/**
|
|
1349
|
+
* 处理滚动事件(隐藏复制按钮和文本块高亮,并更新当前页码)
|
|
1350
|
+
*/
|
|
1351
|
+
const handleScroll = () => {
|
|
1352
|
+
if (hideTimer) {
|
|
1353
|
+
clearTimeout(hideTimer);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
// 隐藏复制按钮
|
|
1357
|
+
showCopyButton.value = false;
|
|
1358
|
+
|
|
1359
|
+
// 移除文本块高亮(但保留目录点击的高亮,因为滚动不应该清除目录高亮)
|
|
1360
|
+
if (activeBlockDiv.value && !isTocHighlighted.value) {
|
|
1361
|
+
activeBlockDiv.value.style.backgroundColor = "transparent";
|
|
1362
|
+
activeBlockDiv.value.style.boxShadow = "none";
|
|
1363
|
+
activeBlockDiv.value = null;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
// 移除印章高亮(但保留印章点击的高亮,因为滚动不应该清除印章高亮)
|
|
1367
|
+
if (highlightedSealDiv.value && !isSealHighlighted.value) {
|
|
1368
|
+
highlightedSealDiv.value.style.backgroundColor = "transparent";
|
|
1369
|
+
highlightedSealDiv.value.style.boxShadow = "none";
|
|
1370
|
+
highlightedSealDiv.value = null;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
// 更新当前页码(根据滚动位置)
|
|
1374
|
+
updateCurrentPageFromScroll();
|
|
1375
|
+
};
|
|
1376
|
+
|
|
1377
|
+
/**
|
|
1378
|
+
* 根据滚动位置更新当前页码
|
|
1379
|
+
* 这个函数会找到在视口中最显眼的页面,并将其设置为当前页
|
|
1380
|
+
*/
|
|
1381
|
+
const updateCurrentPageFromScroll = () => {
|
|
1382
|
+
if (!containerRef.value) return;
|
|
1383
|
+
|
|
1384
|
+
const container = containerRef.value;
|
|
1385
|
+
const containerRect = container.getBoundingClientRect();
|
|
1386
|
+
const containerTop = containerRect.top;
|
|
1387
|
+
const containerHeight = container.clientHeight;
|
|
1388
|
+
const containerCenter = containerTop + containerHeight / 2;
|
|
1389
|
+
|
|
1390
|
+
// 遍历所有页面,找到距离视口中心最近的页面
|
|
1391
|
+
let closestPage = 1;
|
|
1392
|
+
let closestDistance = Infinity;
|
|
1393
|
+
|
|
1394
|
+
for (let pageNum = 1; pageNum <= totalPages.value; pageNum++) {
|
|
1395
|
+
const pageElement = container.querySelector(
|
|
1396
|
+
`.pdf-page-container[data-page-number="${pageNum}"]`
|
|
1397
|
+
) as HTMLElement;
|
|
1398
|
+
|
|
1399
|
+
if (pageElement) {
|
|
1400
|
+
// 使用 getBoundingClientRect() 获取实际渲染位置(考虑了 transform: scale)
|
|
1401
|
+
// 而不是使用 offsetTop(不考虑 transform)
|
|
1402
|
+
const pageRect = pageElement.getBoundingClientRect();
|
|
1403
|
+
const pageTop = pageRect.top;
|
|
1404
|
+
const pageHeight = pageRect.height;
|
|
1405
|
+
const pageCenter = pageTop + pageHeight / 2;
|
|
1406
|
+
const distance = Math.abs(pageCenter - containerCenter);
|
|
1407
|
+
|
|
1408
|
+
if (distance < closestDistance) {
|
|
1409
|
+
closestDistance = distance;
|
|
1410
|
+
closestPage = pageNum;
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
currentPage.value = closestPage;
|
|
1416
|
+
};
|
|
1417
|
+
|
|
1418
|
+
/**
|
|
1419
|
+
* 跳转到指定页面
|
|
1420
|
+
* @param pageNum 页码(从1开始)
|
|
1421
|
+
* @param emitEvent 是否触发跳转事件,默认为 true
|
|
1422
|
+
*/
|
|
1423
|
+
const goToPage = (pageNum: number, emitEvent: boolean = true) => {
|
|
1424
|
+
if (!containerRef.value || pageNum < 1 || pageNum > totalPages.value) return;
|
|
1425
|
+
|
|
1426
|
+
const pageElement = containerRef.value.querySelector(
|
|
1427
|
+
`.pdf-page-container[data-page-number="${pageNum}"]`
|
|
1428
|
+
) as HTMLElement;
|
|
1429
|
+
|
|
1430
|
+
if (pageElement) {
|
|
1431
|
+
// 平滑滚动到目标页面
|
|
1432
|
+
pageElement.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
1433
|
+
currentPage.value = pageNum;
|
|
1434
|
+
|
|
1435
|
+
// 触发跳转事件
|
|
1436
|
+
if (emitEvent) {
|
|
1437
|
+
emit("page-jump", pageNum);
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
};
|
|
1441
|
+
|
|
1442
|
+
/**
|
|
1443
|
+
* 处理页码输入变化(实时验证,只允许输入数字)
|
|
1444
|
+
*/
|
|
1445
|
+
const handlePageInputChange = (event: Event) => {
|
|
1446
|
+
const input = event.target as HTMLInputElement;
|
|
1447
|
+
// 只保留数字
|
|
1448
|
+
const value = input.value.replace(/[^\d]/g, "");
|
|
1449
|
+
inputPage.value = value;
|
|
1450
|
+
};
|
|
1451
|
+
|
|
1452
|
+
/**
|
|
1453
|
+
* 处理页码输入失焦(应用页码跳转)
|
|
1454
|
+
*/
|
|
1455
|
+
const handlePageInputBlur = () => {
|
|
1456
|
+
validateAndJumpToPage();
|
|
1457
|
+
};
|
|
1458
|
+
|
|
1459
|
+
/**
|
|
1460
|
+
* 处理回车键(应用页码跳转)
|
|
1461
|
+
*/
|
|
1462
|
+
const handlePageInputEnter = (event: KeyboardEvent) => {
|
|
1463
|
+
(event.target as HTMLInputElement).blur(); // 触发失焦,统一处理
|
|
1464
|
+
};
|
|
1465
|
+
|
|
1466
|
+
/**
|
|
1467
|
+
* 验证并跳转到指定页面
|
|
1468
|
+
*/
|
|
1469
|
+
const validateAndJumpToPage = () => {
|
|
1470
|
+
const pageNum = parseInt(inputPage.value, 10);
|
|
1471
|
+
|
|
1472
|
+
// 如果输入为空或无效,恢复当前页码
|
|
1473
|
+
if (!inputPage.value || isNaN(pageNum)) {
|
|
1474
|
+
inputPage.value = String(currentPage.value);
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
// 限制在最小值和最大值之间
|
|
1479
|
+
let validPageNum = pageNum;
|
|
1480
|
+
if (pageNum < 1) {
|
|
1481
|
+
validPageNum = 1;
|
|
1482
|
+
} else if (pageNum > totalPages.value) {
|
|
1483
|
+
validPageNum = totalPages.value;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
// 更新输入框显示
|
|
1487
|
+
inputPage.value = String(validPageNum);
|
|
1488
|
+
|
|
1489
|
+
// 如果页码有变化,跳转到新页面
|
|
1490
|
+
if (validPageNum !== currentPage.value) {
|
|
1491
|
+
goToPage(validPageNum);
|
|
1492
|
+
}
|
|
1493
|
+
};
|
|
1494
|
+
|
|
1495
|
+
/**
|
|
1496
|
+
* 监听 pdfUrl 变化,重新加载 PDF
|
|
1497
|
+
*/
|
|
1498
|
+
watch(
|
|
1499
|
+
() => props.pdfUrl,
|
|
1500
|
+
async (newUrl) => {
|
|
1501
|
+
if (newUrl) {
|
|
1502
|
+
// 等待下一个tick确保DOM已更新
|
|
1503
|
+
await nextTick();
|
|
1504
|
+
await loadPDF();
|
|
1505
|
+
}
|
|
1506
|
+
},
|
|
1507
|
+
{ immediate: false }
|
|
1508
|
+
); // 不立即执行,等待onMounted
|
|
1509
|
+
|
|
1510
|
+
/**
|
|
1511
|
+
* 从 blocksData 中提取目录和印章信息
|
|
1512
|
+
*/
|
|
1513
|
+
const extractTocAndSeals = () => {
|
|
1514
|
+
if (!props.blocksData || props.blocksData.length === 0) {
|
|
1515
|
+
tocList.value = [];
|
|
1516
|
+
sealList.value = [];
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
// 提取目录信息
|
|
1521
|
+
const tocItems: TocItem[] = [];
|
|
1522
|
+
props.blocksData.forEach((block, index) => {
|
|
1523
|
+
if (
|
|
1524
|
+
block.blockLabel === "doc_title" ||
|
|
1525
|
+
block.blockLabel === "paragraph_title"
|
|
1526
|
+
) {
|
|
1527
|
+
tocItems.push({
|
|
1528
|
+
id: `toc-${index}`,
|
|
1529
|
+
type: block.blockLabel as "doc_title" | "paragraph_title",
|
|
1530
|
+
content: block.blockContent,
|
|
1531
|
+
page: block.blockPage,
|
|
1532
|
+
blockBbox: block.blockBbox,
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
});
|
|
1536
|
+
|
|
1537
|
+
// 提取印章信息
|
|
1538
|
+
const seals: SealItem[] = [];
|
|
1539
|
+
const sealMap = new Map<string, number>(); // 用于统计每页的印章索引
|
|
1540
|
+
props.blocksData.forEach((block, index) => {
|
|
1541
|
+
if (block.blockLabel === "seal") {
|
|
1542
|
+
const key = `${block.blockPage}`;
|
|
1543
|
+
const currentCount = sealMap.get(key) || 0;
|
|
1544
|
+
const sealIndex = currentCount + 1; // 印章索引从1开始
|
|
1545
|
+
sealMap.set(key, sealIndex);
|
|
1546
|
+
|
|
1547
|
+
seals.push({
|
|
1548
|
+
id: `seal-${index}`,
|
|
1549
|
+
name: `印章${block.blockPage}-${sealIndex}`,
|
|
1550
|
+
page: block.blockPage,
|
|
1551
|
+
index: sealIndex,
|
|
1552
|
+
blockBbox: block.blockBbox,
|
|
1553
|
+
});
|
|
1554
|
+
}
|
|
1555
|
+
});
|
|
1556
|
+
|
|
1557
|
+
tocList.value = tocItems;
|
|
1558
|
+
sealList.value = seals;
|
|
1559
|
+
};
|
|
1560
|
+
|
|
1561
|
+
/**
|
|
1562
|
+
* 检查元素是否在视口内可见
|
|
1563
|
+
* @param element 要检查的元素
|
|
1564
|
+
* @param container 滚动容器
|
|
1565
|
+
* @returns 是否在视口内可见
|
|
1566
|
+
*/
|
|
1567
|
+
const isElementVisible = (
|
|
1568
|
+
element: HTMLElement,
|
|
1569
|
+
container: HTMLElement
|
|
1570
|
+
): boolean => {
|
|
1571
|
+
const elementRect = element.getBoundingClientRect();
|
|
1572
|
+
const containerRect = container.getBoundingClientRect();
|
|
1573
|
+
|
|
1574
|
+
// 检查元素是否在容器的视口内(考虑一些边距,允许部分可见)
|
|
1575
|
+
return (
|
|
1576
|
+
elementRect.top < containerRect.bottom &&
|
|
1577
|
+
elementRect.bottom > containerRect.top &&
|
|
1578
|
+
elementRect.left < containerRect.right &&
|
|
1579
|
+
elementRect.right > containerRect.left
|
|
1580
|
+
);
|
|
1581
|
+
};
|
|
1582
|
+
|
|
1583
|
+
/**
|
|
1584
|
+
* 统一的高亮函数,通过坐标自动匹配文本块或印章
|
|
1585
|
+
* @param bbox 块的边界框
|
|
1586
|
+
* @param pageNum 页码
|
|
1587
|
+
* @param shouldScroll 是否应该滚动到元素位置
|
|
1588
|
+
* @returns 匹配到的元素类型:'block' | 'seal' | null(未找到)
|
|
1589
|
+
*/
|
|
1590
|
+
const highlightPosition = (
|
|
1591
|
+
bbox: [number, number, number, number],
|
|
1592
|
+
pageNum: number,
|
|
1593
|
+
shouldScroll: boolean = true
|
|
1594
|
+
): "block" | "seal" | null => {
|
|
1595
|
+
// 清除之前的高亮
|
|
1596
|
+
if (activeBlockDiv.value) {
|
|
1597
|
+
activeBlockDiv.value.style.backgroundColor = "transparent";
|
|
1598
|
+
activeBlockDiv.value.style.boxShadow = "none";
|
|
1599
|
+
activeBlockDiv.value = null;
|
|
1600
|
+
}
|
|
1601
|
+
isTocHighlighted.value = false;
|
|
1602
|
+
|
|
1603
|
+
if (highlightedSealDiv.value) {
|
|
1604
|
+
highlightedSealDiv.value.style.backgroundColor = "transparent";
|
|
1605
|
+
highlightedSealDiv.value.style.boxShadow = "none";
|
|
1606
|
+
highlightedSealDiv.value = null;
|
|
1607
|
+
}
|
|
1608
|
+
isSealHighlighted.value = false;
|
|
1609
|
+
|
|
1610
|
+
const textLayer = textLayerRefs.get(pageNum);
|
|
1611
|
+
if (!textLayer) return null;
|
|
1612
|
+
|
|
1613
|
+
// 查找对应的块元素,使用存储的bbox数据匹配(使用容差)
|
|
1614
|
+
const blockDivs = textLayer.querySelectorAll(".text-block");
|
|
1615
|
+
const tolerance = 2; // 容差,允许2px的误差
|
|
1616
|
+
|
|
1617
|
+
let matchedElement: HTMLElement | null = null;
|
|
1618
|
+
let matchedType: "block" | "seal" | null = null;
|
|
1619
|
+
|
|
1620
|
+
blockDivs.forEach((div) => {
|
|
1621
|
+
const el = div as HTMLElement;
|
|
1622
|
+
const storedBbox = el.dataset.bbox;
|
|
1623
|
+
if (!storedBbox) return;
|
|
1624
|
+
|
|
1625
|
+
try {
|
|
1626
|
+
const parsedBbox = JSON.parse(storedBbox) as [
|
|
1627
|
+
number,
|
|
1628
|
+
number,
|
|
1629
|
+
number,
|
|
1630
|
+
number
|
|
1631
|
+
];
|
|
1632
|
+
|
|
1633
|
+
// 使用容差匹配bbox坐标(允许小的误差)
|
|
1634
|
+
const isMatch =
|
|
1635
|
+
Math.abs(parsedBbox[0] - bbox[0]) < tolerance &&
|
|
1636
|
+
Math.abs(parsedBbox[1] - bbox[1]) < tolerance &&
|
|
1637
|
+
Math.abs(parsedBbox[2] - bbox[2]) < tolerance &&
|
|
1638
|
+
Math.abs(parsedBbox[3] - bbox[3]) < tolerance;
|
|
1639
|
+
|
|
1640
|
+
if (isMatch) {
|
|
1641
|
+
matchedElement = el;
|
|
1642
|
+
// 根据元素的 label 自动判断类型
|
|
1643
|
+
const label = el.dataset.label;
|
|
1644
|
+
matchedType = label === "seal" ? "seal" : "block";
|
|
1645
|
+
}
|
|
1646
|
+
} catch (error) {
|
|
1647
|
+
console.warn("解析bbox失败:", error);
|
|
1648
|
+
}
|
|
1649
|
+
});
|
|
1650
|
+
|
|
1651
|
+
if (!matchedElement || !matchedType) return null;
|
|
1652
|
+
|
|
1653
|
+
// 保存引用,避免在 setTimeout 中访问可能为 null 的值
|
|
1654
|
+
const elementRef = matchedElement;
|
|
1655
|
+
const typeRef = matchedType;
|
|
1656
|
+
|
|
1657
|
+
// 根据类型设置不同的状态变量
|
|
1658
|
+
if (typeRef === "seal") {
|
|
1659
|
+
highlightedSealDiv.value = elementRef;
|
|
1660
|
+
isSealHighlighted.value = true;
|
|
1661
|
+
} else {
|
|
1662
|
+
activeBlockDiv.value = elementRef;
|
|
1663
|
+
isTocHighlighted.value = true;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
// 使用一致的高亮样式
|
|
1667
|
+
elementRef.style.backgroundColor =
|
|
1668
|
+
"var(--s-color-brand-primary-transparent-3, rgba(0, 102, 255, .15))";
|
|
1669
|
+
elementRef.style.boxShadow = "0 0 0 2px rgba(30, 144, 255, 0.6)";
|
|
1670
|
+
|
|
1671
|
+
// 只有在需要滚动且元素不在视口内时才滚动
|
|
1672
|
+
if (shouldScroll && containerRef.value) {
|
|
1673
|
+
const isVisible = isElementVisible(elementRef, containerRef.value);
|
|
1674
|
+
if (!isVisible) {
|
|
1675
|
+
// 元素不在视口内,滚动到该元素位置(确保可见)
|
|
1676
|
+
elementRef.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
// 5秒后自动取消高亮
|
|
1681
|
+
setTimeout(() => {
|
|
1682
|
+
if (typeRef === "seal" && highlightedSealDiv.value === elementRef) {
|
|
1683
|
+
elementRef.style.backgroundColor = "transparent";
|
|
1684
|
+
elementRef.style.boxShadow = "none";
|
|
1685
|
+
highlightedSealDiv.value = null;
|
|
1686
|
+
isSealHighlighted.value = false;
|
|
1687
|
+
} else if (typeRef === "block" && activeBlockDiv.value === elementRef) {
|
|
1688
|
+
elementRef.style.backgroundColor = "transparent";
|
|
1689
|
+
elementRef.style.boxShadow = "none";
|
|
1690
|
+
activeBlockDiv.value = null;
|
|
1691
|
+
isTocHighlighted.value = false;
|
|
1692
|
+
}
|
|
1693
|
+
}, 5000);
|
|
1694
|
+
|
|
1695
|
+
return typeRef;
|
|
1696
|
+
};
|
|
1697
|
+
|
|
1698
|
+
/**
|
|
1699
|
+
* 跳转到指定位置并高亮(统一的外部调用接口)
|
|
1700
|
+
* 通过坐标自动匹配文本块或印章,无需指定类型
|
|
1701
|
+
* @param pageNum 页码(从1开始)
|
|
1702
|
+
* @param bbox 位置的边界框 [x1, y1, x2, y2]
|
|
1703
|
+
* @param emitEvent 是否触发跳转事件,默认为 true
|
|
1704
|
+
*/
|
|
1705
|
+
const jumpToPosition = (
|
|
1706
|
+
pageNum: number,
|
|
1707
|
+
bbox: [number, number, number, number],
|
|
1708
|
+
emitEvent: boolean = true
|
|
1709
|
+
) => {
|
|
1710
|
+
if (pageNum < 1 || pageNum > totalPages.value) {
|
|
1711
|
+
console.warn(`页码 ${pageNum} 超出范围 [1, ${totalPages.value}]`);
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
// 先跳转到对应页面
|
|
1716
|
+
goToPage(pageNum, false); // 内部跳转不触发事件,统一在最后触发
|
|
1717
|
+
|
|
1718
|
+
// 等待页面跳转完成后再高亮
|
|
1719
|
+
nextTick(() => {
|
|
1720
|
+
let retryCount = 0;
|
|
1721
|
+
const maxRetries = 5;
|
|
1722
|
+
const retryDelay = 200;
|
|
1723
|
+
|
|
1724
|
+
const tryHighlight = () => {
|
|
1725
|
+
const matchedType = highlightPosition(bbox, pageNum, true);
|
|
1726
|
+
if (matchedType) {
|
|
1727
|
+
// 高亮成功,触发事件
|
|
1728
|
+
if (emitEvent) {
|
|
1729
|
+
emit("position-jump", pageNum, bbox, matchedType);
|
|
1730
|
+
}
|
|
1731
|
+
} else if (retryCount < maxRetries) {
|
|
1732
|
+
retryCount++;
|
|
1733
|
+
setTimeout(tryHighlight, retryDelay);
|
|
1734
|
+
} else {
|
|
1735
|
+
console.warn(`无法找到并高亮指定位置: 页码 ${pageNum}, bbox: [${bbox.join(", ")}]`);
|
|
1736
|
+
}
|
|
1737
|
+
};
|
|
1738
|
+
|
|
1739
|
+
setTimeout(tryHighlight, 300);
|
|
1740
|
+
});
|
|
1741
|
+
};
|
|
1742
|
+
|
|
1743
|
+
/**
|
|
1744
|
+
* 处理目录项点击,跳转并高亮(内部使用)
|
|
1745
|
+
*/
|
|
1746
|
+
const jumpToBlock = (item: TocItem) => {
|
|
1747
|
+
// 先尝试找到元素,检查是否已经在当前页面且可见
|
|
1748
|
+
const textLayer = textLayerRefs.get(item.page);
|
|
1749
|
+
if (textLayer && containerRef.value) {
|
|
1750
|
+
const blockDivs = textLayer.querySelectorAll(".text-block");
|
|
1751
|
+
const tolerance = 2;
|
|
1752
|
+
|
|
1753
|
+
// 尝试找到对应的块元素
|
|
1754
|
+
let foundElement: HTMLElement | null = null;
|
|
1755
|
+
Array.from(blockDivs).forEach((div) => {
|
|
1756
|
+
if (foundElement) return; // 已经找到了,跳过
|
|
1757
|
+
|
|
1758
|
+
const el = div as HTMLElement;
|
|
1759
|
+
const storedBbox = el.dataset.bbox;
|
|
1760
|
+
|
|
1761
|
+
if (!storedBbox) return;
|
|
1762
|
+
|
|
1763
|
+
try {
|
|
1764
|
+
const parsedBbox = JSON.parse(storedBbox) as [
|
|
1765
|
+
number,
|
|
1766
|
+
number,
|
|
1767
|
+
number,
|
|
1768
|
+
number
|
|
1769
|
+
];
|
|
1770
|
+
|
|
1771
|
+
const isMatch =
|
|
1772
|
+
Math.abs(parsedBbox[0] - item.blockBbox[0]) < tolerance &&
|
|
1773
|
+
Math.abs(parsedBbox[1] - item.blockBbox[1]) < tolerance &&
|
|
1774
|
+
Math.abs(parsedBbox[2] - item.blockBbox[2]) < tolerance &&
|
|
1775
|
+
Math.abs(parsedBbox[3] - item.blockBbox[3]) < tolerance;
|
|
1776
|
+
|
|
1777
|
+
if (isMatch) {
|
|
1778
|
+
foundElement = el;
|
|
1779
|
+
}
|
|
1780
|
+
} catch (error) {
|
|
1781
|
+
// 忽略解析错误
|
|
1782
|
+
}
|
|
1783
|
+
});
|
|
1784
|
+
|
|
1785
|
+
// 如果找到了元素
|
|
1786
|
+
if (foundElement) {
|
|
1787
|
+
// 检查元素是否已经在视口内可见
|
|
1788
|
+
const isVisible = isElementVisible(foundElement, containerRef.value);
|
|
1789
|
+
|
|
1790
|
+
// 如果元素已经在当前页面且可见,只高亮不滚动
|
|
1791
|
+
if (item.page === currentPage.value && isVisible) {
|
|
1792
|
+
highlightBlock(item.blockBbox, item.page, false); // 传入 false 表示不滚动
|
|
1793
|
+
return; // 直接返回,不执行后续跳转
|
|
1794
|
+
}
|
|
1795
|
+
// 如果找到了但不在当前页面或不可见,继续执行后续跳转逻辑
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
// 如果元素不在当前页面或不可见,需要跳转
|
|
1800
|
+
// 先跳转到对应页面
|
|
1801
|
+
goToPage(item.page);
|
|
1802
|
+
|
|
1803
|
+
// 等待页面跳转完成后再高亮,增加延迟并添加重试机制
|
|
1804
|
+
nextTick(() => {
|
|
1805
|
+
let retryCount = 0;
|
|
1806
|
+
const maxRetries = 5;
|
|
1807
|
+
const retryDelay = 200;
|
|
1808
|
+
|
|
1809
|
+
const tryHighlight = () => {
|
|
1810
|
+
const success = highlightBlock(item.blockBbox, item.page, true); // 传入 true 表示需要滚动
|
|
1811
|
+
if (success) {
|
|
1812
|
+
// 高亮成功,触发事件(自动检测类型)
|
|
1813
|
+
const actualType = highlightPosition(item.blockBbox, item.page, false) || "block";
|
|
1814
|
+
emit("position-jump", item.page, item.blockBbox, actualType);
|
|
1815
|
+
} else if (retryCount < maxRetries) {
|
|
1816
|
+
retryCount++;
|
|
1817
|
+
setTimeout(tryHighlight, retryDelay);
|
|
1818
|
+
}
|
|
1819
|
+
};
|
|
1820
|
+
|
|
1821
|
+
setTimeout(tryHighlight, 300);
|
|
1822
|
+
});
|
|
1823
|
+
};
|
|
1824
|
+
|
|
1825
|
+
|
|
1826
|
+
/**
|
|
1827
|
+
* 处理印章点击,跳转并高亮(内部使用)
|
|
1828
|
+
*/
|
|
1829
|
+
const jumpToSeal = (seal: SealItem) => {
|
|
1830
|
+
// 先尝试找到元素,检查是否已经在当前页面且可见
|
|
1831
|
+
const textLayer = textLayerRefs.get(seal.page);
|
|
1832
|
+
if (textLayer && containerRef.value) {
|
|
1833
|
+
const blockDivs = textLayer.querySelectorAll(".text-block");
|
|
1834
|
+
const tolerance = 2;
|
|
1835
|
+
|
|
1836
|
+
// 尝试找到对应的块元素
|
|
1837
|
+
let foundElement: HTMLElement | null = null;
|
|
1838
|
+
Array.from(blockDivs).forEach((div) => {
|
|
1839
|
+
if (foundElement) return; // 已经找到了,跳过
|
|
1840
|
+
|
|
1841
|
+
const el = div as HTMLElement;
|
|
1842
|
+
const label = el.dataset.label;
|
|
1843
|
+
|
|
1844
|
+
// 只处理seal类型的块
|
|
1845
|
+
if (label !== "seal") return;
|
|
1846
|
+
|
|
1847
|
+
const storedBbox = el.dataset.bbox;
|
|
1848
|
+
if (!storedBbox) return;
|
|
1849
|
+
|
|
1850
|
+
try {
|
|
1851
|
+
const parsedBbox = JSON.parse(storedBbox) as [
|
|
1852
|
+
number,
|
|
1853
|
+
number,
|
|
1854
|
+
number,
|
|
1855
|
+
number
|
|
1856
|
+
];
|
|
1857
|
+
|
|
1858
|
+
const isMatch =
|
|
1859
|
+
Math.abs(parsedBbox[0] - seal.blockBbox[0]) < tolerance &&
|
|
1860
|
+
Math.abs(parsedBbox[1] - seal.blockBbox[1]) < tolerance &&
|
|
1861
|
+
Math.abs(parsedBbox[2] - seal.blockBbox[2]) < tolerance &&
|
|
1862
|
+
Math.abs(parsedBbox[3] - seal.blockBbox[3]) < tolerance;
|
|
1863
|
+
|
|
1864
|
+
if (isMatch) {
|
|
1865
|
+
foundElement = el;
|
|
1866
|
+
}
|
|
1867
|
+
} catch (error) {
|
|
1868
|
+
// 忽略解析错误
|
|
1869
|
+
}
|
|
1870
|
+
});
|
|
1871
|
+
|
|
1872
|
+
// 如果找到了元素
|
|
1873
|
+
if (foundElement) {
|
|
1874
|
+
// 检查元素是否已经在视口内可见
|
|
1875
|
+
const isVisible = isElementVisible(foundElement, containerRef.value);
|
|
1876
|
+
|
|
1877
|
+
// 如果元素已经在当前页面且可见,只高亮不滚动
|
|
1878
|
+
if (seal.page === currentPage.value && isVisible) {
|
|
1879
|
+
highlightSeal(seal.blockBbox, seal.page, false); // 传入 false 表示不滚动
|
|
1880
|
+
return; // 直接返回,不执行后续跳转
|
|
1881
|
+
}
|
|
1882
|
+
// 如果找到了但不在当前页面或不可见,继续执行后续跳转逻辑
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
// 如果元素不在当前页面或不可见,需要跳转
|
|
1887
|
+
// 先跳转到对应页面
|
|
1888
|
+
goToPage(seal.page);
|
|
1889
|
+
|
|
1890
|
+
// 等待页面跳转完成后再高亮,增加延迟并添加重试机制
|
|
1891
|
+
nextTick(() => {
|
|
1892
|
+
let retryCount = 0;
|
|
1893
|
+
const maxRetries = 5;
|
|
1894
|
+
const retryDelay = 200;
|
|
1895
|
+
|
|
1896
|
+
const tryHighlight = () => {
|
|
1897
|
+
const success = highlightSeal(seal.blockBbox, seal.page, true); // 传入 true 表示需要滚动
|
|
1898
|
+
if (success) {
|
|
1899
|
+
// 高亮成功,触发事件
|
|
1900
|
+
emit("position-jump", seal.page, seal.blockBbox, "seal");
|
|
1901
|
+
} else if (retryCount < maxRetries) {
|
|
1902
|
+
retryCount++;
|
|
1903
|
+
setTimeout(tryHighlight, retryDelay);
|
|
1904
|
+
}
|
|
1905
|
+
};
|
|
1906
|
+
|
|
1907
|
+
setTimeout(tryHighlight, 300);
|
|
1908
|
+
});
|
|
1909
|
+
};
|
|
1910
|
+
|
|
1911
|
+
/**
|
|
1912
|
+
* 高亮指定的文本块(内部使用,保持向后兼容)
|
|
1913
|
+
* @param bbox 块的边界框
|
|
1914
|
+
* @param pageNum 页码
|
|
1915
|
+
* @param shouldScroll 是否应该滚动到元素位置(如果元素不在视口内)
|
|
1916
|
+
* @returns 是否成功找到并高亮了元素
|
|
1917
|
+
*/
|
|
1918
|
+
const highlightBlock = (
|
|
1919
|
+
bbox: [number, number, number, number],
|
|
1920
|
+
pageNum: number,
|
|
1921
|
+
shouldScroll: boolean = true
|
|
1922
|
+
): boolean => {
|
|
1923
|
+
return highlightPosition(bbox, pageNum, shouldScroll) === "block";
|
|
1924
|
+
};
|
|
1925
|
+
|
|
1926
|
+
/**
|
|
1927
|
+
* 高亮指定的印章(内部使用,保持向后兼容)
|
|
1928
|
+
* @param bbox 印章的边界框
|
|
1929
|
+
* @param pageNum 页码
|
|
1930
|
+
* @param shouldScroll 是否应该滚动到元素位置(如果元素不在视口内)
|
|
1931
|
+
* @returns 是否成功找到并高亮了元素
|
|
1932
|
+
*/
|
|
1933
|
+
const highlightSeal = (
|
|
1934
|
+
bbox: [number, number, number, number],
|
|
1935
|
+
pageNum: number,
|
|
1936
|
+
shouldScroll: boolean = true
|
|
1937
|
+
): boolean => {
|
|
1938
|
+
return highlightPosition(bbox, pageNum, shouldScroll) === "seal";
|
|
1939
|
+
};
|
|
1940
|
+
|
|
1941
|
+
/**
|
|
1942
|
+
* 监听 blocksData 变化,增量渲染文本图层(避免闪烁)
|
|
1943
|
+
*
|
|
1944
|
+
* 🔑 优化逻辑:
|
|
1945
|
+
* 1. 使用防抖避免频繁触发(流式解析时每页都会触发)
|
|
1946
|
+
* 2. 只渲染新增的页面,已渲染的页面不重新渲染(避免清空导致的高亮闪烁)
|
|
1947
|
+
* 3. 如果用户正在交互(显示复制按钮),则不重新渲染已渲染的页面
|
|
1948
|
+
*/
|
|
1949
|
+
watch(
|
|
1950
|
+
() => props.blocksData,
|
|
1951
|
+
async (newBlocks) => {
|
|
1952
|
+
// 提取目录和印章信息
|
|
1953
|
+
extractTocAndSeals();
|
|
1954
|
+
if (!newBlocks || newBlocks.length === 0 || !pdfDoc.value) {
|
|
1955
|
+
return;
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
// 清除之前的防抖定时器
|
|
1959
|
+
if (renderDebounceTimer) {
|
|
1960
|
+
clearTimeout(renderDebounceTimer);
|
|
1961
|
+
renderDebounceTimer = null;
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
// 防抖:延迟 100ms 执行,避免频繁触发
|
|
1965
|
+
renderDebounceTimer = setTimeout(async () => {
|
|
1966
|
+
await nextTick();
|
|
1967
|
+
|
|
1968
|
+
// 获取所有有数据的页面号
|
|
1969
|
+
const pagesWithData = new Set<number>();
|
|
1970
|
+
newBlocks.forEach((block) => {
|
|
1971
|
+
if (block.blockPage && block.blockPage <= totalPages.value) {
|
|
1972
|
+
pagesWithData.add(block.blockPage);
|
|
1973
|
+
}
|
|
1974
|
+
});
|
|
1975
|
+
|
|
1976
|
+
// 只渲染新增的页面(未渲染过的页面)
|
|
1977
|
+
// 或者已标记为已渲染但实际没有渲染内容的页面(textLayer为空)
|
|
1978
|
+
// 这样可以避免重新渲染已渲染的页面,防止高亮框闪烁
|
|
1979
|
+
const pagesToRender: number[] = [];
|
|
1980
|
+
pagesWithData.forEach((pageNum) => {
|
|
1981
|
+
if (!renderedPages.value.has(pageNum)) {
|
|
1982
|
+
// 未渲染过的页面,需要渲染
|
|
1983
|
+
pagesToRender.push(pageNum);
|
|
1984
|
+
} else {
|
|
1985
|
+
// 已标记为已渲染,但需要检查是否真的渲染过
|
|
1986
|
+
const textLayer = textLayerRefs.get(pageNum);
|
|
1987
|
+
if (textLayer && textLayer.children.length === 0) {
|
|
1988
|
+
// 如果textLayer为空,说明之前没有真正渲染过,需要重新渲染
|
|
1989
|
+
// 从已渲染集合中移除,允许重新渲染
|
|
1990
|
+
renderedPages.value.delete(pageNum);
|
|
1991
|
+
pagesToRender.push(pageNum);
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
});
|
|
1995
|
+
|
|
1996
|
+
// 如果有新增的页面,才进行渲染
|
|
1997
|
+
if (pagesToRender.length > 0) {
|
|
1998
|
+
// 如果用户正在交互(显示复制按钮),暂不渲染新页面
|
|
1999
|
+
// 这样可以避免在用户交互时打断体验
|
|
2000
|
+
if (showCopyButton.value) {
|
|
2001
|
+
// 延迟渲染,等待用户交互完成
|
|
2002
|
+
setTimeout(async () => {
|
|
2003
|
+
for (const pageNum of pagesToRender) {
|
|
2004
|
+
const page = await pdfDoc.value.getPage(pageNum);
|
|
2005
|
+
const viewport = page.getViewport({ scale: 1.0 });
|
|
2006
|
+
await renderTextLayer(page, viewport, pageNum);
|
|
2007
|
+
}
|
|
2008
|
+
}, 500);
|
|
2009
|
+
} else {
|
|
2010
|
+
// 正常渲染新增页面
|
|
2011
|
+
for (const pageNum of pagesToRender) {
|
|
2012
|
+
const page = await pdfDoc.value.getPage(pageNum);
|
|
2013
|
+
const viewport = page.getViewport({ scale: 1.0 });
|
|
2014
|
+
await renderTextLayer(page, viewport, pageNum);
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
}, 100);
|
|
2019
|
+
},
|
|
2020
|
+
{ immediate: false, deep: true }
|
|
2021
|
+
);
|
|
2022
|
+
|
|
2023
|
+
/**
|
|
2024
|
+
* 监听缩放模式变化
|
|
2025
|
+
*/
|
|
2026
|
+
watch(scaleMode, (newMode) => {
|
|
2027
|
+
if (newMode !== "custom") {
|
|
2028
|
+
handleScaleModeChange(newMode);
|
|
2029
|
+
}
|
|
2030
|
+
});
|
|
2031
|
+
|
|
2032
|
+
/**
|
|
2033
|
+
* 监听侧边栏展开/收起,重新计算PDF缩放比例
|
|
2034
|
+
* 当侧边栏展开或收起时,容器宽度会变化,需要重新计算缩放以保持正确的边距
|
|
2035
|
+
*/
|
|
2036
|
+
watch(showTocSidebar, () => {
|
|
2037
|
+
// 等待DOM更新
|
|
2038
|
+
nextTick(() => {
|
|
2039
|
+
// 使用双重 requestAnimationFrame 确保布局已更新
|
|
2040
|
+
requestAnimationFrame(() => {
|
|
2041
|
+
requestAnimationFrame(() => {
|
|
2042
|
+
// 等待CSS transition完成(侧边栏transition是0.3s)
|
|
2043
|
+
// 使用略大于transition时间的延迟确保布局稳定
|
|
2044
|
+
setTimeout(() => {
|
|
2045
|
+
// 检查容器和canvas是否已准备好
|
|
2046
|
+
if (!containerRef.value || !canvasRefs.get(1)) {
|
|
2047
|
+
return;
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
// 如果当前是自动缩放模式或其他非自定义模式,重新计算缩放
|
|
2051
|
+
if (scaleMode.value === "auto") {
|
|
2052
|
+
handleScaleModeChange("auto");
|
|
2053
|
+
} else if (scaleMode.value !== "custom") {
|
|
2054
|
+
handleScaleModeChange(scaleMode.value);
|
|
2055
|
+
}
|
|
2056
|
+
}, 350); // 略大于transition时间(300ms)
|
|
2057
|
+
});
|
|
2058
|
+
});
|
|
2059
|
+
});
|
|
2060
|
+
});
|
|
2061
|
+
|
|
2062
|
+
/**
|
|
2063
|
+
* 监听当前页码变化,同步更新输入框
|
|
2064
|
+
*/
|
|
2065
|
+
watch(currentPage, (newPage) => {
|
|
2066
|
+
inputPage.value = String(newPage);
|
|
2067
|
+
});
|
|
2068
|
+
|
|
2069
|
+
/**
|
|
2070
|
+
* 监听 showTocSidebar 属性变化,确保内部状态同步
|
|
2071
|
+
* 当属性为 false 时,强制关闭侧边栏
|
|
2072
|
+
*/
|
|
2073
|
+
watch(
|
|
2074
|
+
() => props.showTocSidebar,
|
|
2075
|
+
(newValue) => {
|
|
2076
|
+
if (newValue === false) {
|
|
2077
|
+
showTocSidebar.value = false;
|
|
2078
|
+
}
|
|
2079
|
+
},
|
|
2080
|
+
{ immediate: true }
|
|
2081
|
+
);
|
|
2082
|
+
|
|
2083
|
+
/**
|
|
2084
|
+
* 组件挂载时的初始化
|
|
2085
|
+
*/
|
|
2086
|
+
onMounted(async () => {
|
|
2087
|
+
// 监听全屏状态变化
|
|
2088
|
+
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
|
2089
|
+
|
|
2090
|
+
// 监听窗口大小变化,自动调整缩放
|
|
2091
|
+
window.addEventListener("resize", () => {
|
|
2092
|
+
if (scaleMode.value === "auto") {
|
|
2093
|
+
handleScaleModeChange("auto");
|
|
2094
|
+
}
|
|
2095
|
+
});
|
|
2096
|
+
|
|
2097
|
+
// 如果有 pdfUrl,立即加载
|
|
2098
|
+
if (props.pdfUrl) {
|
|
2099
|
+
await nextTick(); // 确保DOM已准备好
|
|
2100
|
+
await loadPDF();
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
// 初始化目录和印章列表
|
|
2104
|
+
extractTocAndSeals();
|
|
2105
|
+
});
|
|
2106
|
+
|
|
2107
|
+
/**
|
|
2108
|
+
* 清理函数(兼容性方法,调用 reset)
|
|
2109
|
+
*/
|
|
2110
|
+
const cleanup = () => {
|
|
2111
|
+
reset();
|
|
2112
|
+
};
|
|
2113
|
+
|
|
2114
|
+
/**
|
|
2115
|
+
* 暴露方法给父组件
|
|
2116
|
+
*/
|
|
2117
|
+
defineExpose({
|
|
2118
|
+
reset,
|
|
2119
|
+
cleanup,
|
|
2120
|
+
// 跳转相关方法
|
|
2121
|
+
goToPage, // 跳转到指定页面
|
|
2122
|
+
jumpToPosition, // 跳转到指定位置并高亮(统一接口,自动识别类型)
|
|
2123
|
+
// 向后兼容的别名方法(内部调用统一接口)
|
|
2124
|
+
jumpToBlockPosition: (pageNum: number, bbox: [number, number, number, number], emitEvent?: boolean) =>
|
|
2125
|
+
jumpToPosition(pageNum, bbox, emitEvent),
|
|
2126
|
+
jumpToSealPosition: (pageNum: number, bbox: [number, number, number, number], emitEvent?: boolean) =>
|
|
2127
|
+
jumpToPosition(pageNum, bbox, emitEvent),
|
|
2128
|
+
// 获取当前状态
|
|
2129
|
+
getCurrentPage: () => currentPage.value, // 获取当前页码
|
|
2130
|
+
getTotalPages: () => totalPages.value, // 获取总页数
|
|
2131
|
+
});
|
|
2132
|
+
|
|
2133
|
+
/**
|
|
2134
|
+
* 组件卸载前的清理
|
|
2135
|
+
*/
|
|
2136
|
+
onBeforeUnmount(() => {
|
|
2137
|
+
document.removeEventListener("fullscreenchange", handleFullscreenChange);
|
|
2138
|
+
|
|
2139
|
+
// 清理防抖定时器
|
|
2140
|
+
if (renderDebounceTimer) {
|
|
2141
|
+
clearTimeout(renderDebounceTimer);
|
|
2142
|
+
renderDebounceTimer = null;
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
// 清理隐藏定时器
|
|
2146
|
+
if (hideTimer) {
|
|
2147
|
+
clearTimeout(hideTimer);
|
|
2148
|
+
hideTimer = null;
|
|
2149
|
+
}
|
|
2150
|
+
});
|
|
2151
|
+
</script>
|
|
2152
|
+
|
|
2153
|
+
<style lang="less" scoped>
|
|
2154
|
+
// 淡入动画(从下方向上淡入)
|
|
2155
|
+
@keyframes fade-in {
|
|
2156
|
+
from {
|
|
2157
|
+
opacity: 0;
|
|
2158
|
+
transform: translateY(8px); // 从下方 8px 处开始
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
to {
|
|
2162
|
+
opacity: 1;
|
|
2163
|
+
transform: translateY(0); // 移动到最终位置
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
// 样式已统一到公共样式文件,使用 .preview-container
|
|
2168
|
+
|
|
2169
|
+
// 自定义页码输入框样式
|
|
2170
|
+
.page-input {
|
|
2171
|
+
min-width: 0 !important;
|
|
2172
|
+
padding: 0 6px; // 左右padding(从4px调整到6px,更宽松舒适)
|
|
2173
|
+
font-size: 13px; // 字体大小
|
|
2174
|
+
font-weight: 500; // 中等粗体
|
|
2175
|
+
line-height: 20px; // 行高
|
|
2176
|
+
color: rgb(0 0 0 / 85%); // 文字颜色
|
|
2177
|
+
text-align: center; // 居中显示
|
|
2178
|
+
background: #0000000a; // 浅灰背景
|
|
2179
|
+
border: none; // 无边框
|
|
2180
|
+
border-radius: 4px; // 圆角
|
|
2181
|
+
outline: none; // 移除焦点轮廓
|
|
2182
|
+
transition: background-color 0.15s ease;
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
// 工具栏样式已统一到公共样式文件,使用 .preview-toolbar .preview-toolbar-between
|
|
2186
|
+
// PDF 特有的页码信息样式(在工具栏内)
|
|
2187
|
+
.preview-toolbar {
|
|
2188
|
+
.page-info {
|
|
2189
|
+
display: flex;
|
|
2190
|
+
gap: 4px;
|
|
2191
|
+
align-items: center;
|
|
2192
|
+
font-size: 14px;
|
|
2193
|
+
|
|
2194
|
+
.page-separator {
|
|
2195
|
+
margin: 0 4px;
|
|
2196
|
+
color: #86909c;
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
.total-pages {
|
|
2200
|
+
min-width: 30px;
|
|
2201
|
+
font-size: 13px !important;
|
|
2202
|
+
font-weight: 500;
|
|
2203
|
+
color: rgb(0 0 0 / 85%);
|
|
2204
|
+
text-align: left;
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
.zoom-level {
|
|
2209
|
+
min-width: 50px;
|
|
2210
|
+
font-size: 14px;
|
|
2211
|
+
font-weight: 500;
|
|
2212
|
+
color: #1d2129;
|
|
2213
|
+
text-align: center;
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
// PDF 内容区域
|
|
2218
|
+
.pdf-content {
|
|
2219
|
+
position: relative;
|
|
2220
|
+
flex: 1;
|
|
2221
|
+
overflow: hidden auto;
|
|
2222
|
+
background-color: rgb(243 244 246);
|
|
2223
|
+
transition: width 0.3s ease;
|
|
2224
|
+
|
|
2225
|
+
// 自定义滚动条(浅色主题配色)
|
|
2226
|
+
&::-webkit-scrollbar {
|
|
2227
|
+
width: 6px;
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
&::-webkit-scrollbar-thumb {
|
|
2231
|
+
background-color: #d1d5db;
|
|
2232
|
+
border-radius: 3px;
|
|
2233
|
+
|
|
2234
|
+
&:hover {
|
|
2235
|
+
background-color: rgb(0 0 0 / 30%); // hover时稍微深一点
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
&::-webkit-scrollbar-track {
|
|
2240
|
+
background-color: rgb(243 244 246); // 非常浅的灰色背景
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
// 全屏样式
|
|
2244
|
+
&.fullscreen {
|
|
2245
|
+
position: fixed;
|
|
2246
|
+
top: 0;
|
|
2247
|
+
left: 0;
|
|
2248
|
+
z-index: 9999;
|
|
2249
|
+
width: 100vw;
|
|
2250
|
+
height: 100vh;
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
// PDF 查看器
|
|
2255
|
+
.pdf-viewer {
|
|
2256
|
+
display: flex;
|
|
2257
|
+
align-items: flex-start;
|
|
2258
|
+
justify-content: center;
|
|
2259
|
+
width: 100%;
|
|
2260
|
+
padding: 0 5px;
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
// PDF 缩放包装器
|
|
2264
|
+
.pdf-scale-wrapper {
|
|
2265
|
+
display: flex;
|
|
2266
|
+
flex-direction: column;
|
|
2267
|
+
gap: 10px;
|
|
2268
|
+
align-items: center;
|
|
2269
|
+
height: calc(100vh - 120px);
|
|
2270
|
+
transition: transform 0.2s ease;
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
// PDF 页面容器
|
|
2274
|
+
.pdf-page-container {
|
|
2275
|
+
position: relative; // 设置为相对定位,方便文本图层定位
|
|
2276
|
+
display: inline-block;
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
// PDF 画布
|
|
2280
|
+
.pdf-canvas {
|
|
2281
|
+
display: block;
|
|
2282
|
+
background-color: #fff;
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
// 文本图层样式
|
|
2286
|
+
.text-layer {
|
|
2287
|
+
position: absolute;
|
|
2288
|
+
top: 0;
|
|
2289
|
+
left: 0;
|
|
2290
|
+
width: 100%; // 与画布相同的宽度
|
|
2291
|
+
height: 100%; // 与画布相同的高度
|
|
2292
|
+
pointer-events: auto; // 允许鼠标事件
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
// 复制按钮浮层
|
|
2296
|
+
.copy-button-popup {
|
|
2297
|
+
position: absolute;
|
|
2298
|
+
z-index: 1000;
|
|
2299
|
+
display: flex;
|
|
2300
|
+
flex-direction: row;
|
|
2301
|
+
width: fit-content;
|
|
2302
|
+
padding: 4px;
|
|
2303
|
+
pointer-events: auto; // 确保可以点击
|
|
2304
|
+
background-color: #fff;
|
|
2305
|
+
border-radius: 6px;
|
|
2306
|
+
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
|
|
2307
|
+
animation: fade-in 0.2s ease; // 添加动画效果
|
|
2308
|
+
.copy-button-popup-action {
|
|
2309
|
+
display: flex;
|
|
2310
|
+
flex-direction: row;
|
|
2311
|
+
align-items: center;
|
|
2312
|
+
height: 30px;
|
|
2313
|
+
padding: 0 6px;
|
|
2314
|
+
font-size: 12px;
|
|
2315
|
+
color: #1d2129;
|
|
2316
|
+
white-space: nowrap;
|
|
2317
|
+
cursor: pointer;
|
|
2318
|
+
border-radius: 4px;
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
.copy-button-popup-action:hover {
|
|
2322
|
+
background-color: #f0f0f0;
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
// 🎨 缩放模式下拉面板样式(根据截图精确还原)
|
|
2327
|
+
:deep(.scale-mode-option) {
|
|
2328
|
+
padding: 0 !important;
|
|
2329
|
+
|
|
2330
|
+
.arco-dropdown-option-content {
|
|
2331
|
+
padding: 0 !important;
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
// 🔑 面板整体添加左右内边距,让背景不占满
|
|
2336
|
+
:deep(.arco-dropdown) {
|
|
2337
|
+
.arco-dropdown-list {
|
|
2338
|
+
padding: 4px; // 面板内边距,让选项背景有呼吸空间
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
.scale-mode-item {
|
|
2343
|
+
display: flex;
|
|
2344
|
+
gap: 10px; // 图标与文字间距(从12px调整到10px,与16px图标更协调)
|
|
2345
|
+
align-items: center;
|
|
2346
|
+
width: 100%;
|
|
2347
|
+
min-width: 150px; // 确保面板有足够宽度(略微减小)
|
|
2348
|
+
padding: 8px 12px; // 上下8px,左右12px(更紧凑,与14px字体更协调)
|
|
2349
|
+
cursor: pointer;
|
|
2350
|
+
|
|
2351
|
+
// 图标样式(16px)
|
|
2352
|
+
.scale-mode-item-icon {
|
|
2353
|
+
flex-shrink: 0;
|
|
2354
|
+
width: 16px;
|
|
2355
|
+
height: 16px;
|
|
2356
|
+
color: #000; // 深黑色(截图中未选中图标较深)
|
|
2357
|
+
transition: color 0.15s ease-in-out; // 与背景保持一致的过渡时间
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
// 文字标签样式
|
|
2361
|
+
.scale-mode-item-label {
|
|
2362
|
+
flex: 1;
|
|
2363
|
+
font-size: 14px; // 标准选项文字大小
|
|
2364
|
+
line-height: 20px; // 保持合适的行高确保垂直居中
|
|
2365
|
+
color: #000; // 深黑色(截图中文字较深)
|
|
2366
|
+
white-space: nowrap;
|
|
2367
|
+
transition: color 0.15s ease-in-out; // 与背景保持一致的过渡时间
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
// 对勾图标样式
|
|
2371
|
+
.scale-mode-item-check {
|
|
2372
|
+
flex-shrink: 0;
|
|
2373
|
+
width: 16px;
|
|
2374
|
+
height: 16px;
|
|
2375
|
+
color: #165dff; // 蓝色对勾
|
|
2376
|
+
// 对勾不需要过渡效果,因为它只是显示/隐藏
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
// 选中状态:蓝色高亮
|
|
2380
|
+
&.scale-mode-item-active {
|
|
2381
|
+
// 选中状态保持透明背景,只改变文字和图标颜色
|
|
2382
|
+
background-color: transparent;
|
|
2383
|
+
|
|
2384
|
+
.scale-mode-item-icon {
|
|
2385
|
+
color: #165dff; // 蓝色图标
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
.scale-mode-item-label {
|
|
2389
|
+
font-weight: 400; // 正常字重(截图中没有特别加粗)
|
|
2390
|
+
color: #165dff; // 蓝色文字
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
// 选中 + Hover 组合状态
|
|
2395
|
+
&.scale-mode-item-active:hover {
|
|
2396
|
+
background-color: #f5f5f5; // hover时依然显示背景
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
// 主内容区域容器
|
|
2401
|
+
.pdf-main-container {
|
|
2402
|
+
display: flex;
|
|
2403
|
+
flex: 1;
|
|
2404
|
+
width: 100%;
|
|
2405
|
+
height: 100%;
|
|
2406
|
+
overflow: hidden;
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
// 目录和印章侧边栏样式
|
|
2410
|
+
.toc-sidebar {
|
|
2411
|
+
display: flex;
|
|
2412
|
+
flex-direction: column;
|
|
2413
|
+
width: 0;
|
|
2414
|
+
height: 100%;
|
|
2415
|
+
overflow: hidden;
|
|
2416
|
+
background: #f3f4f5;
|
|
2417
|
+
border-right: 1px solid #00000014;
|
|
2418
|
+
transition: width 0.3s ease;
|
|
2419
|
+
|
|
2420
|
+
&.toc-sidebar-show {
|
|
2421
|
+
width: 180px;
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
.toc-tab-switcher {
|
|
2425
|
+
display: flex;
|
|
2426
|
+
flex-shrink: 0;
|
|
2427
|
+
gap: 2px;
|
|
2428
|
+
align-items: center;
|
|
2429
|
+
height: 34px;
|
|
2430
|
+
padding: 2px 4px;
|
|
2431
|
+
margin: 16px 16px 8px;
|
|
2432
|
+
background: var(--s-color-bg-trans-primary, rgb(0 0 0 / 6%));
|
|
2433
|
+
border: 0.5px solid rgb(0 0 0 / 8%);
|
|
2434
|
+
border: 0.5px solid var(--s-color-border-tertiary, rgb(0 0 0 / 8%));
|
|
2435
|
+
border-radius: 10px;
|
|
2436
|
+
|
|
2437
|
+
.toc-tab-item {
|
|
2438
|
+
display: flex;
|
|
2439
|
+
flex-shrink: 0;
|
|
2440
|
+
align-items: center;
|
|
2441
|
+
justify-content: center;
|
|
2442
|
+
width: 50%;
|
|
2443
|
+
height: 28px;
|
|
2444
|
+
color: #86909c;
|
|
2445
|
+
cursor: pointer;
|
|
2446
|
+
border-radius: 8px;
|
|
2447
|
+
transition: all 0.2s ease;
|
|
2448
|
+
|
|
2449
|
+
&:hover {
|
|
2450
|
+
color: #1d2129;
|
|
2451
|
+
background-color: #f5f5f5;
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
&.toc-tab-item-active {
|
|
2455
|
+
color: #1d2129;
|
|
2456
|
+
background-color: #fff;
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
.toc-sidebar-content {
|
|
2462
|
+
flex: 1;
|
|
2463
|
+
padding: 8px 0;
|
|
2464
|
+
overflow: hidden auto;
|
|
2465
|
+
|
|
2466
|
+
// 自定义滚动条样式
|
|
2467
|
+
&::-webkit-scrollbar {
|
|
2468
|
+
width: 6px;
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
&::-webkit-scrollbar-thumb {
|
|
2472
|
+
background-color: #d1d5db;
|
|
2473
|
+
border-radius: 3px;
|
|
2474
|
+
|
|
2475
|
+
&:hover {
|
|
2476
|
+
background-color: rgb(0 0 0 / 30%);
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
&::-webkit-scrollbar-track {
|
|
2481
|
+
background-color: transparent;
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
// 目录和印章列表样式
|
|
2487
|
+
.toc-tab-content {
|
|
2488
|
+
padding: 4px 0;
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
.toc-item {
|
|
2492
|
+
display: flex;
|
|
2493
|
+
flex-direction: column;
|
|
2494
|
+
align-items: flex-start;
|
|
2495
|
+
padding: 6px 12px;
|
|
2496
|
+
font-size: 12px;
|
|
2497
|
+
line-height: 1.5;
|
|
2498
|
+
cursor: pointer;
|
|
2499
|
+
transition: background-color 0.2s ease;
|
|
2500
|
+
|
|
2501
|
+
&:hover {
|
|
2502
|
+
background-color: #f5f5f5;
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
.toc-item-content {
|
|
2506
|
+
flex: 1;
|
|
2507
|
+
color: #1d2129;
|
|
2508
|
+
word-break: break-word;
|
|
2509
|
+
white-space: normal;
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
.toc-item-page {
|
|
2513
|
+
flex-shrink: 0;
|
|
2514
|
+
margin-top: 2px;
|
|
2515
|
+
font-size: 11px;
|
|
2516
|
+
color: #86909c;
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
// 文档标题样式
|
|
2520
|
+
&.toc-item-doc-title {
|
|
2521
|
+
padding-left: 12px;
|
|
2522
|
+
font-weight: 600;
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
// 段落标题样式(缩进显示层级关系)
|
|
2526
|
+
&.toc-item-paragraph {
|
|
2527
|
+
padding-left: 24px;
|
|
2528
|
+
font-weight: 400;
|
|
2529
|
+
|
|
2530
|
+
.toc-item-content {
|
|
2531
|
+
color: #1d2129;
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
// 印章样式
|
|
2536
|
+
&.toc-item-seal {
|
|
2537
|
+
padding-left: 24px;
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
.toc-empty {
|
|
2542
|
+
padding: 24px 12px;
|
|
2543
|
+
font-size: 12px;
|
|
2544
|
+
color: #86909c;
|
|
2545
|
+
text-align: center;
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
// 印章图片弹窗样式
|
|
2549
|
+
.seal-image-modal-content {
|
|
2550
|
+
display: flex;
|
|
2551
|
+
align-items: center;
|
|
2552
|
+
justify-content: center;
|
|
2553
|
+
min-height: 200px;
|
|
2554
|
+
padding: 20px;
|
|
2555
|
+
|
|
2556
|
+
.seal-image {
|
|
2557
|
+
max-width: 100%;
|
|
2558
|
+
max-height: 70vh;
|
|
2559
|
+
object-fit: contain;
|
|
2560
|
+
border-radius: 8px;
|
|
2561
|
+
box-shadow: 0 4px 12px rgb(0 0 0 / 10%);
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
.empty-image {
|
|
2565
|
+
padding: 60px;
|
|
2566
|
+
font-size: 14px;
|
|
2567
|
+
color: #86909c;
|
|
2568
|
+
text-align: center;
|
|
2569
|
+
}
|
|
2570
|
+
}
|
|
2571
|
+
</style>
|