预览主页面组件代码:
<template>
<div class="app">
<!-- 初始加载状态(API请求进行中时显示) -->
<div v-if="isProcessing && !pdfUint8" class="initial-loading">
<div class="loading-content">
<div class="loading-spinner"></div>
<p>{{ progressText || "正在加载文件..." }}</p>
</div>
</div>
<!-- 主要内容区域 -->
<main class="main-container">
<!-- 预览区域 -->
<div v-if="showPreview && pdfUint8" id="preview-container">
<!-- PDF预览面板 -->
<div class="preview-panel left-panel">
<div class="preview-panel-header">
<div class="panel-title">
<svg
t="1766807061206"
class="icon-PDF"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="4122"
width="200"
height="200"
>
<path
d="M776.704 988.16H247.296c-84.48 0-153.6-69.632-153.6-154.624V190.464c0-84.992 69.12-154.624 153.6-154.624h378.88c40.96 0 79.36 18.944 104.448 50.688L901.12 304.64c18.432 23.552 28.672 53.248 28.672 82.944v445.952c0.512 84.992-68.608 154.624-153.088 154.624z"
fill="#FFEEEF"
p-id="4123"
></path>
<path
d="M776.704 1024H247.296c-104.448 0-189.44-85.504-189.44-190.464V190.464C57.856 85.504 142.848 0 247.296 0h378.88C678.4 0 726.528 23.552 758.784 65.024l170.496 217.6c23.552 29.696 36.352 67.072 36.352 104.96v445.952c0.512 104.96-84.48 190.464-188.928 190.464zM247.296 72.192c-65.024 0-117.76 53.248-117.76 118.272v643.072c0 65.536 52.736 118.272 117.76 118.272h528.896c65.024 0 117.76-53.248 117.76-118.272V387.584c0-21.504-7.168-43.008-20.992-60.928l-170.496-217.6c-18.432-23.552-46.592-36.864-76.288-36.864h-378.88z"
fill="#FFB8BE"
p-id="4124"
></path>
<path
d="M302.592 792.576c-10.24 0-19.456-4.096-23.04-5.632-17.92-7.68-28.672-23.552-28.672-43.008 0-12.288 0-50.688 140.8-111.616 31.744-57.856 56.32-115.712 73.216-172.032-15.872-33.792-54.272-118.784-27.136-164.352 7.68-15.872 26.624-25.6 48.128-25.6 15.872 0 30.72 7.68 40.448 19.968 20.48 28.672 17.92 86.016-6.144 161.28 20.48 37.888 48.64 74.752 83.968 110.08l4.096-0.512c28.672-4.608 55.296-9.216 84.48-9.216 34.304 1.536 57.856 9.728 70.656 25.088 9.728 11.776 10.24 24.576 9.216 31.232 1.536 15.36-1.536 27.648-9.728 36.352-15.36 16.896-44.032 16.896-62.464 16.896-38.4-2.56-75.264-17.408-107.52-43.008-53.76 11.776-106.496 28.16-165.376 52.224-45.056 80.896-87.04 121.856-124.928 121.856z m33.28-75.264c-6.144 4.096-11.264 8.704-16.896 13.824 6.656-3.584 11.776-8.192 16.896-13.824zM495.616 535.04c-9.728 25.088-22.016 51.712-36.352 80.384a1061.123 1061.123 0 0 1 81.408-23.04l-5.12-5.12h6.144c-14.336-16.896-29.184-35.328-43.52-53.76l-2.56 1.536z m185.344 70.656c6.656 1.536 13.824 2.56 22.016 2.56H704c2.56 0.512 5.12 1.024 7.68 1.024s4.608 0 7.168-0.512c-4.608-1.536-11.776-3.072-24.064-3.072H680.96z m-195.584-281.6c-3.584 16.896-2.56 34.304 2.048 51.2 3.072-16.896 2.56-34.304-0.512-51.2h-1.536z"
fill="#FF5462"
p-id="4125"
></path>
</svg>
<div class="title">PDF预览</div>
</div>
</div>
<div class="preview-panel-content">
<PdfViewer
ref="pdfViewer"
:pdf-file="selectedPdfFile"
:pdf-uint8="pdfUint8"
:pdf-elements="pdfElements"
:zoom-level="zoomLevel"
:is-sync-scroll="isSyncScroll"
@update:zoom-level="zoomLevel = $event"
@pdf-loaded="handlePdfLoaded"
@element-clicked="handlePdfElementClicked"
@scroll="handlePdfScroll"
@error="handlePdfError"
/>
</div>
</div>
<div class="fenge-box"></div>
<!-- Markdown预览面板 -->
<div class="preview-panel right-panel">
<div class="preview-panel-header">
<div class="panel-title">
<svg
t="1766807103339"
class="icon-Markdown"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="4269"
width="200"
height="200"
>
<path
d="M776.704 988.16H247.296c-84.48 0-153.6-69.632-153.6-154.624V190.464c0-84.992 69.12-154.624 153.6-154.624h378.88c40.96 0 79.36 18.944 104.448 50.688L901.12 304.64c18.432 23.552 28.672 53.248 28.672 82.944v445.952c0.512 84.992-68.608 154.624-153.088 154.624z"
fill="#F9EEFE"
p-id="4270"
></path>
<path
d="M776.704 1024H247.296c-104.448 0-189.44-85.504-189.44-190.464V190.464C57.856 85.504 142.848 0 247.296 0h378.88C678.4 0 726.528 23.552 758.784 65.024l170.496 217.6c23.552 29.696 36.352 67.072 36.352 104.96v445.952c0.512 104.96-84.48 190.464-188.928 190.464zM247.296 72.192c-65.024 0-117.76 53.248-117.76 118.272v643.072c0 65.536 52.736 118.272 117.76 118.272h528.896c65.024 0 117.76-53.248 117.76-118.272V387.584c0-21.504-7.168-43.008-20.992-60.928l-170.496-217.6c-18.432-23.552-46.592-36.864-76.288-36.864h-378.88z"
fill="#EAB8FF"
p-id="4271"
data-spm-anchor-id="a313x.manage_type_myprojects.0.i8.567c3a81S5fuDO"
></path>
<path
d="M302.08 354.304h142.336l66.56 225.28h3.584l69.12-225.28h138.24v400.384h-86.528v-314.88h-3.584l-77.312 239.104h-87.552l-76.288-239.104h-3.584v314.88H302.08V354.304z"
fill="#C753F8"
p-id="4272"
></path>
</svg>
<div class="title">Markdown预览</div>
</div>
</div>
<div class="preview-panel-content">
<MarkdownRenderer
ref="markdownRenderer"
:markdown-content="markdownContent"
:is-sync-scroll="isSyncScroll"
:file-name="pdfFileName"
@element-clicked="handleMdElementClicked"
@scroll="handleMdScroll"
@elements-created="handleMdElementsCreated"
/>
</div>
</div>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import PdfViewer from "./components/PdfViewer.vue";
import MarkdownRenderer from "./components/MarkdownRenderer.vue";
import {
loadPdfDocumentApi,
extractPdfElements,
generateMarkdownContent,
createElementMappings,
parseJsonLayoutInfo,
generatePdfElementsFromLayout,
} from "./../../utils/pdfUtils";
import {
initializeGlobalFunctions,
cleanupGlobalFunctions,
} from "./../../utils/globalFunctions";
import { useZipJsonAndPdf } from "./../../utils/useZipJsonAndPdf";
import type {
PdfElement,
MarkdownElement,
ElementMapping,
} from "./../../types";
// 使用defineProps定义props
const props = defineProps({
id: {
type: String,
default: "",
},
});
// 使用API加载ZIP和PDF的钩子
const {
error,
jsonData,
pdfUint8,
pdfName,
loadFromUrl,
} = useZipJsonAndPdf();
const pdfViewer = ref<any>(null);
const markdownRenderer = ref<any>(null);
// 响应式数据
const selectedPdfFile = ref<File | null>(null);
const pdfFileName = ref("");
const showPreview = ref(true);
const isProcessing = ref(false);
const progress = ref(0);
const progressText = ref("");
const zoomLevel = ref(1.2);
const isSyncScroll = ref(true);
// 通知系统
const notification = ref({
show: false,
type: "success" as "success" | "error" | "warning" | "info",
message: "",
});
// PDF相关数据
const pdfDoc = shallowRef<any>(null);
const pdfElements = ref<PdfElement\[\]>(\[\]);
const markdownContent = ref("");
const mdElements = ref<MarkdownElement\[\]>(\[\]);
const elementMappings = ref<ElementMapping\[\]>(\[\]);
const jsonContent = ref<any>(null);
// 类型定义
type PdfScrollState = {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
currentPage: number;
pageProgress: number;
};
type SyncSource = "pdf-click" | "md-click" | "pdf-scroll" | null;
// 滚动同步状态(token机制替代定时器标志位)
const pdfScrollInfo = ref<PdfScrollState>({
scrollTop: 0,
scrollHeight: 0,
clientHeight: 0,
currentPage: 1,
pageProgress: 0,
});
const mdScrollInfo = ref({ scrollTop: 0, scrollHeight: 0, clientHeight: 0 });
const syncSource = ref<SyncSource>(null);
let syncActionToken = 0;
let pdfScrollSyncFrame: number | null = null;
let pendingPdfScrollState: PdfScrollState | null = null;
let lastPdfScrollSyncKey = "";
// 记录最后一次程序滚动PDF的时间,过滤延迟到达的scroll事件避免反向同步闪动
let lastProgrammaticPdfScrollAt = 0;
const PROGRAMMATIC_SCROLL_GUARD_MS = 200;
// 同步控制:使用token机制避免定时器叠加和竞态条件
function beginSync(source: Exclude<SyncSource, null>) {
syncActionToken += 1;
syncSource.value = source;
return syncActionToken;
}
function endSync(token: number) {
if (syncActionToken === token) {
syncSource.value = null;
}
}
// 获取指定页面的Markdown元素(按文档顺序排序)
function getSortedMdElementsForPage(pageNum: number) {
const sortByDocumentIndex = (elements: MarkdownElement\[\]) => {
return ...elements.sort((a, b) => {
const aIndex = a.globalIndex ?? a.fullIndex ?? a.elementIndex ?? 0;
const bIndex = b.globalIndex ?? b.fullIndex ?? b.elementIndex ?? 0;
return aIndex - bIndex;
});
};
const exactPageElements = mdElements.value.filter((el) => el.page === pageNum);
if (exactPageElements.length > 0) {
return sortByDocumentIndex(exactPageElements);
}
return sortByDocumentIndex(
mdElements.value.filter(
(el) =>
(el.page === undefined || el.page === null) &&
el.estimatedPage === pageNum
)
);
}
// PDF滚动连续同步到Markdown(基于页面进度,替代离散page-changed)
async function syncMarkdownToPdfPosition(
pageNum: number,
pageProgress: number
) {
if (!isSyncScroll.value || !markdownRenderer.value) return;
const pageMdElements = getSortedMdElementsForPage(pageNum);
if (pageMdElements.length === 0) return;
const startId = pageMdElements0.id;
const endId = pageMdElementspageMdElements.length - 1.id;
const progress = Math.max(0, Math.min(pageProgress, 1));
const syncKey = `{pageNum}:{Math.round(progress * 100)}`;
if (syncKey === lastPdfScrollSyncKey) return;
lastPdfScrollSyncKey = syncKey;
const token = beginSync("pdf-scroll");
try {
if (typeof markdownRenderer.value.scrollToPageRangeProgress === "function") {
await markdownRenderer.value.scrollToPageRangeProgress(
startId,
endId,
progress
);
} else if (typeof markdownRenderer.value.scrollToElement === "function") {
await markdownRenderer.value.scrollToElement(startId);
}
} finally {
endSync(token);
}
}
// 使用requestAnimationFrame节流PDF滚动同步
function schedulePdfScrollSync(scrollInfo: PdfScrollState) {
pendingPdfScrollState = scrollInfo;
if (pdfScrollSyncFrame !== null) return;
pdfScrollSyncFrame = window.requestAnimationFrame(() => {
pdfScrollSyncFrame = null;
const nextState = pendingPdfScrollState;
pendingPdfScrollState = null;
if (!nextState) return;
void syncMarkdownToPdfPosition(
nextState.currentPage,
nextState.pageProgress
);
});
}
// 处理PDF加载完成
function handlePdfLoaded(pdfDocument: any) {
pdfDoc.value = pdfDocument;
}
// 处理PDF元素点击
async function handlePdfElementClicked(elementId: string) {
const mapping = elementMappings.value.find((m) => m.pdfId === elementId);
if (mapping && markdownRenderer.value) {
const token = beginSync("pdf-click");
try {
markdownRenderer.value.highlightMdElement(mapping.mdId);
if (
typeof markdownRenderer.value.scrollToElementWithOverlay === "function"
) {
await markdownRenderer.value.scrollToElementWithOverlay(
mapping.mdId,
"center"
);
} else if (
typeof markdownRenderer.value.scrollToElementCentered === "function"
) {
await markdownRenderer.value.scrollToElementCentered(mapping.mdId);
} else {
await markdownRenderer.value.scrollToElement(mapping.mdId);
}
} finally {
endSync(token);
}
}
}
// 处理Markdown元素点击
async function handleMdElementClicked(elementId: string) {
const mapping = elementMappings.value.find((m) => m.mdId === elementId);
if (mapping && pdfViewer.value) {
const token = beginSync("md-click");
try {
if (typeof pdfViewer.value.scrollToPdfElement === "function") {
await pdfViewer.value.scrollToPdfElement(mapping.pdfId);
} else {
pdfViewer.value.highlightPdfElement(mapping.pdfId, true);
}
// 记录程序滚动时间:pdf的scroll事件经rAF节流后会延迟到达,
// 此时token可能已释放,需用时间戳守卫防止反向同步md造成闪动
lastProgrammaticPdfScrollAt = Date.now();
} finally {
endSync(token);
}
}
}
// 处理PDF滚动
function handlePdfScroll(scrollInfo: PdfScrollState) {
pdfScrollInfo.value = scrollInfo;
if (!isSyncScroll.value || syncSource.value === "md-click") return;
// 过滤程序滚动PDF产生的延迟scroll事件,避免反向同步导致md闪动
if (Date.now() - lastProgrammaticPdfScrollAt < PROGRAMMATIC_SCROLL_GUARD_MS) return;
schedulePdfScrollSync(scrollInfo);
}
// 处理Markdown滚动
function handleMdScroll(scrollInfo: {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
}) {
mdScrollInfo.value = scrollInfo;
}
// 处理Markdown元素创建
function handleMdElementsCreated(elements: MarkdownElement\[\]) {
mdElements.value = elements;
// 估算Markdown元素对应的PDF页面
estimateMdElementPages(elements);
// 创建元素映射
elementMappings.value = createElementMappings(pdfElements.value, elements);
}
// 估算Markdown元素对应的PDF页面(仅为缺少真实page信息的元素估算)
function estimateMdElementPages(mdElements: MarkdownElement\[\]) {
if (pdfElements.value.length === 0 || mdElements.length === 0) return;
// 统计PDF元素在各页的分布
const pageStats: Record<number, number> = {};
pdfElements.value.forEach((pdfElement) => {
const page = pdfElement.page;
pageStatspage = (pageStatspage || 0) + 1;
});
// 计算每页的PDF元素数量比例
const totalPdfElements = pdfElements.value.length;
let currentRatio = 0;
const sortedPages = Object.keys(pageStats).map(Number).sort((a, b) => a - b);
const pageIntervals: {
page: number;
startRatio: number;
endRatio: number;
}\[\] = \[\];
sortedPages.forEach((page) => {
const count = pageStatspage;
const ratio = count / totalPdfElements;
const startRatio = currentRatio;
const endRatio = currentRatio + ratio;
pageIntervals.push({ page, startRatio, endRatio });
currentRatio = endRatio;
});
// 仅为缺少真实 page 信息的 Markdown 元素估算页面,避免污染真实映射
mdElements.forEach((mdElement, index) => {
if (mdElement.page !== undefined && mdElement.page !== null) {
mdElement.estimatedPage = undefined;
return;
}
const mdRatio =
mdElements.length > 1 ? index / (mdElements.length - 1) : 0;
let matchedPage = sortedPagessortedPages.length - 1;
for (const interval of pageIntervals) {
if (mdRatio >= interval.startRatio && mdRatio <= interval.endRatio) {
matchedPage = interval.page;
break;
}
}
mdElement.estimatedPage = matchedPage;
});
}
// 处理PDF错误
function handlePdfError(errorMessage: string) {
console.error("PDF错误:", errorMessage);
error.value = errorMessage;
showNotification("PDF加载失败: " + errorMessage, "error");
}
// 切换同步滚动
function toggleSyncScroll() {
isSyncScroll.value = !isSyncScroll.value;
// 清理同步状态,确保切换时没有残留影响
syncSource.value = null;
pendingPdfScrollState = null;
lastPdfScrollSyncKey = "";
if (pdfScrollSyncFrame !== null) {
window.cancelAnimationFrame(pdfScrollSyncFrame);
pdfScrollSyncFrame = null;
}
showNotification(
isSyncScroll.value ? "同步滚动已开启" : "同步滚动已关闭",
"info"
);
}
// 显示通知
function showNotification(
message: string,
type: "success" | "error" | "warning" | "info" = "info"
) {
notification.value = {
show: true,
type,
message,
};
// 3秒后自动隐藏
setTimeout(() => {
notification.value.show = false;
}, 3000);
}
// 键盘快捷键处理
function handleKeyboard(event: KeyboardEvent) {
if (event.ctrlKey || event.metaKey) {
switch (event.key) {
case "+":
event.preventDefault();
if (zoomLevel.value < 3.0) {
zoomLevel.value += 0.05;
}
break;
case "-":
event.preventDefault();
if (zoomLevel.value > 0.5) {
zoomLevel.value -= 0.05;
}
break;
case "0":
event.preventDefault();
zoomLevel.value = 1.0;
break;
case "s":
event.preventDefault();
toggleSyncScroll();
break;
}
}
}
// 组件挂载
onMounted(async () => {
// 初始化全局函数
initializeGlobalFunctions();
// 添加键盘事件监听
document.addEventListener("keydown", handleKeyboard);
// 添加窗口大小变化监听
window.addEventListener("resize", handleResize);
// 直接从API加载数据
try {
isProcessing.value = true;
progressText.value = "正在从API加载数据...";
// 调用API加载数据
await loadFromUrl(props.id);
// 如果API成功获取了数据,更新文件名
if (pdfName.value) {
pdfFileName.value = pdfName.value;
}
// 如果API返回了JSON数据,使用它
if (jsonData.value) {
jsonContent.value = jsonData.value;
}
// 加载PDF文档
if (pdfUint8.value) {
pdfDoc.value = await loadPdfDocumentApi(pdfUint8.value);
progressText.value = "正在提取PDF元素...";
// 提取PDF元素
if (jsonContent.value) {
// 使用JSON布局信息
const layoutInfo = parseJsonLayoutInfo(jsonContent.value);
pdfElements.value = generatePdfElementsFromLayout(layoutInfo);
} else {
// 直接从PDF提取
pdfElements.value = await extractPdfElements(pdfDoc.value);
}
progressText.value = "正在生成Markdown内容...";
// 生成Markdown内容
markdownContent.value = generateMarkdownContent(pdfElements.value);
progress.value = 100;
progressText.value = "处理完成";
}
} catch (err) {
console.error("从API加载数据失败:", err);
} finally {
isProcessing.value = false;
}
});
// 组件卸载
onUnmounted(() => {
// 清理全局函数
cleanupGlobalFunctions();
// 移除事件监听
document.removeEventListener("keydown", handleKeyboard);
window.removeEventListener("resize", handleResize);
// 清理待执行的滚动同步动画帧
if (pdfScrollSyncFrame !== null) {
window.cancelAnimationFrame(pdfScrollSyncFrame);
pdfScrollSyncFrame = null;
}
});
// 处理窗口大小变化
function handleResize() {
// 可以在这里添加窗口大小变化的处理逻辑
}
</script>
<style lang="scss" scoped>
/* 初始加载状态(样式根据自己需求定义) */
.initial-loading {
display: flex;
justify-content: center;
align-items: center;
min-height: 90vh;
background: #fff;
border-radius: 0 0 10px 10px;
}
.loading-content {
text-align: center;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid #f3f3f3;
border-top: 3px solid $bg-color;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.loading-content p {
color: #666;
font-size: 14px;
margin: 0;
}
/* 主容器 */
.main-container {
flex: 1;
padding: $spacing-md;
margin: 0 auto;
width: 100%;
height: 100%; // 确保高度继承
overflow: hidden;
display: flex;
flex-direction: column;
position: relative;
}
/* 预览容器 */
#preview-container {
display: flex;
width: 100%;
height: 100%;
max-width: 100%;
margin: 0;
padding: 0;
overflow: hidden;
position: relative;
border-radius: 0 0 10px 10px;
}
/* 预览面板 */
.preview-panel {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* 预览面板头部 */
.preview-panel-header {
height: 50px;
background: #f9fafb;
display: flex;
align-items: center;
}
/* 面板标题 */
.panel-title {
display: flex;
align-items: center;
margin-left: 16px;
/* PDF面板图标 */
.icon-PDF {
width: 20px;
height: 20px;
margin-right: 8px;
}
/* Markdown面板图标 */
.icon-Markdown {
width: 20px;
height: 20px;
margin-right: 8px;
}
.title {
font-size: 16px;
color: $font-size-primary;
}
}
/* 预览面板内容 */
.preview-panel-content {
flex: 1;
overflow: hidden;
}
/* 动画效果 */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeInScale {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* 工具提示样式 */
title {
position: relative;
}
/* 预览区域布局 */
#preview-container {
display: flex;
width: 100%;
height: calc(100vh - 83px);
max-width: 100vw;
margin: 0;
padding: 0;
overflow: hidden;
position: relative;
}
.left-panel {
border-right: 1px solid #edf0f5;
}
.right-panel {
border-left: 1px solid #edf0f5;
}
.fenge-box {
width: 14px;
height: 100%;
background-color: #fff;
}
/* 按钮样式 */
button {
@include transition(all);
border-radius: $radius-md;
}
button:hover {
transform: translateY(-1px);
}
#theme-toggle,
#help-button {
background: transparent;
border: 1px solid $border-color;
}
#theme-toggle:hover,
#help-button:hover {
background: $bg-secondary;
box-shadow: $shadow-md;
}
/* 响应式设计 */
@include respond-to(medium) {
.grid-cols-1\.lg\:grid-cols-2 {
grid-template-columns: 1fr;
}
.h-\\\calc\\(100vh-12rem\\)\\ {
height: 50vh;
}
/* 在小屏幕上保持左右布局 */
#preview-container {
flex-direction: row;
height: calc(100vh - 7rem);
}
#preview-container > div {
flex: 1;
min-width: 0;
}
}
@include respond-to(small) {
/* 只有在非常小的屏幕上才改为上下布局 */
#preview-container {
flex-direction: column;
height: auto;
min-height: calc(100vh - 7rem);
}
#preview-container > div {
height: 50vh;
min-height: 300px;
}
}
@include respond-to(small) {
.container {
padding-left: $spacing-md;
padding-right: $spacing-md;
}
.text-xl {
font-size: 1.25rem;
}
.text-lg {
font-size: 1.125rem;
}
}
/* 深色主题 */
.dark-theme {
background-color: $bg-dark;
color: $text-light;
}
/* 图标大小样式 */
.icon-large {
font-size: 24px !important;
width: 24px !important;
height: 24px !important;
display: inline-block !important;
}
/* 内联样式替换类 */
.nav-container {
padding: spacing-xs spacing-xl;
}
.app-title {
margin-bottom: 0.45rem !important;
font-size: 1.6rem;
line-height: 2;
color: $text-primary;
}
.title-icon {
margin-right: 0.6rem !important;
color: $color-primary;
}
.panel-icon {
margin-right: 0.5rem !important;
}
/* Flex布局类 */
.flex {
display: flex;
}
.items-center {
align-items: center;
}
.justify-start {
justify-content: flex-start;
}
.justify-between {
justify-content: space-between;
}
.space-x-2 > * + * {
margin-left: $spacing-sm;
}
.flex-grow {
flex-grow: 1;
}
/* 间距类 */
.px-5 {
padding-left: $spacing-lg;
padding-right: $spacing-lg;
}
.mr-2 {
margin-right: $spacing-sm;
}
.mr-3 {
margin-right: $spacing-md;
}
.p-4 {
padding: $spacing-lg;
}
.p-6 {
padding: $spacing-xl;
}
.mb-4 {
margin-bottom: $spacing-md;
}
.ml-2 {
margin-left: $spacing-sm;
}
/* 颜色类 */
.bg-white {
background-color: $bg-primary;
}
.text-gray-800 {
color: $text-primary;
}
.text-primary {
color: $color-primary;
}
.text-blue-500 {
color: $color-primary;
}
.text-red-500 {
color: $color-error;
}
.text-gray-600 {
color: $text-secondary;
}
.text-gray-400 {
color: $text-tertiary;
}
/* 边框和阴影类 */
.border-b {
border-bottom: 1px solid $border-color;
}
.border-gray-200 {
border-color: $border-color;
}
.rounded-lg {
@include radius(lg);
}
.shadow-md {
@include shadow(md);
}
/* 其他类 */
.relative {
position: relative;
}
.absolute {
position: absolute;
}
.top-0 {
top: 0;
}
.right-0 {
right: 0;
}
.overflow-hidden {
overflow: hidden;
}
.text-xl {
font-size: $font-size-xl;
}
.text-lg {
font-size: $font-size-lg;
}
.font-bold {
font-weight: 700;
}
.font-semibold {
font-weight: 600;
}
.font-extrabold {
font-weight: 800;
}
/* 状态类 */
.hover\:bg-gray-100:hover {
background-color: $bg-secondary;
}
.hover\:text-gray-600:hover {
color: $text-secondary;
}
/* 模板硬编码类替换 */
.bg-business {
background-color: $bg-secondary;
}
.bg-nav-gradient {
background: linear-gradient(
135deg,
$color-primary 0%,
$color-primary-dark 100%
);
}
.text-nav-title {
color: white;
font-size: 1.25rem;
}
.btn-circle {
padding: $spacing-md;
border-radius: 50%;
@include transition(all);
background: $bg-primary;
border: 1px solid $border-color;
}
.btn-circle:hover {
background: $bg-hover;
border-color: $border-color-dark;
transform: translateY(-1px);
box-shadow: $shadow-md;
}
.text-icon {
color: $text-secondary;
font-size: 1.5rem;
width: 1.5rem;
height: 1.5rem;
display: inline-block;
text-shadow: none;
}
.mb-section {
margin-bottom: $spacing-lg;
}
.main-container {
padding: 0 $spacing-sm;
overflow: hidden;
/* 确保主容器不会限制预览区域的宽度 */
&:has(#preview-container) {
max-width: 100vw;
padding-left: 0;
padding-right: 0;
margin: 0;
overflow: hidden;
}
}
</style>
PdfViewer组件代码:
<template>
<div class="pdf-viewer">
<!-- PDF容器 -->
<div
id="pdf-container"
ref="pdfContainer"
class="pdf-container"
@scroll="handlePdfScroll"
>
<!-- PDF画布容器 -->
<div id="pdf-canvas-container" class="pdf-canvas-container"></div>
<!-- 加载状态 -->
<div v-if="isLoading" class="loading-overlay">
<div class="loading-spinner"></div>
<p class="loading-text">正在加载PDF文档...</p>
</div>
</div>
<!-- 工具栏 -->
<div class="toolbar">
<div class="left-toolbar">
<el-button
@click="zoomOut"
class="toolbar-btn"
:disabled="
toolbarDisabled || isLoading || isRenderingPages || zoomLevel <= 0.5
"
title="缩小"
>
<span class="icon iconfont icon-suoxiao"></span>
</el-button>
<span class="zoom-level zoom-level-display">
{{ Math.round(zoomLevel * 100) }}%
</span>
<el-button
@click="zoomIn"
class="toolbar-btn toolbar-right"
:disabled="
toolbarDisabled || isLoading || isRenderingPages || zoomLevel >= 3.0
"
title="放大"
>
<span class="icon iconfont icon-fangda"></span>
</el-button>
<el-button
@click="resetZoom"
class="toolbar-reset"
:disabled="toolbarDisabled || isLoading || isRenderingPages"
>
<span class="icon iconfont icon-shuaxin"></span>
<span class="tooltip-text">重置</span>
</el-button>
</div>
<!-- 页码显示和跳转 -->
<div class="page-navigation">
<span class="page-navigation-text">
第 {{ currentPage }} 页 / 共 {{ totalPages }} 页
</span>
<span class="page-navigation-text page-jump-text">跳转到</span>
<el-input
type="number"
v-model="jumpPage"
min="1"
:max="totalPages"
class="page-input"
:disabled="toolbarDisabled || isLoading || isRenderingPages"
placeholder=""
@keyup.enter="goToPage"
/>
<el-button
@click="goToPage"
class="jump-btn"
:disabled="
toolbarDisabled ||
isLoading ||
isRenderingPages ||
jumpPage === null ||
jumpPage < 1 ||
jumpPage > totalPages
"
title="跳转到指定页码"
>
<span class="tooltip-text">跳转 ></span>
</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
loadPdfDocument,
loadPdfDocumentApi,
renderPdfPage,
} from "./../../../utils/pdfUtils";
import {
handlePdfElementHover,
removeTypeLabel,
} from "./../../../utils/globalFunctions";
import type { PdfElement } from "./../../../types";
// Props
interface Props {
pdfFile: File | null;
pdfUint8: Uint8Array | null;
pdfElements: PdfElement\[\];
zoomLevel: number;
isSyncScroll: boolean;
toolbarDisabled?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
pdfFile: null,
pdfUint8: null,
pdfElements: () => \[\],
zoomLevel: 1.2,
isSyncScroll: true,
toolbarDisabled: false, // 添加默认值
});
// Emits
interface Emits {
(e: "update:zoomLevel", value: number): void;
(e: "pdfLoaded", pdfDoc: any): void;
(e: "pageChanged", pageNum: number): void;
(e: "elementClicked", elementId: string): void;
(
e: "scroll",
scrollInfo: {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
currentPage: number;
pageProgress: number;
}
): void;
(e: "error", errorMessage: string): void;
}
const emit = defineEmits<Emits>();
// Refs
const pdfContainer = ref<HTMLElement>();
const pdfCanvasContainer = ref<HTMLElement | null>(null);
const isLoading = ref(false);
const isPdfLoaded = ref(false);
const renderedPages = ref<Map<number, any>>(new Map());
const currentPage = ref(1);
const totalPages = ref(0);
const jumpPage = ref<number | null>(null);
const pageHeight = ref(0);
const pageWidth = ref(0);
const currentHighlightedElementId = ref<string | null>(null);
const isRenderingPages = ref(false);
let pdfDocInstance: any = null;
let zoomRenderTimer: number | null = null;
let scrollSyncFrame: number | null = null;
let pendingScrollInfo: {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
} | null = null;
// 上一次的scrollTop,用于检测拖滚动条时的跳变
let lastScrollTop: number | undefined = undefined;
// 拖滚动条防抖定时器:拖动期间不渲染中间页,停下后再渲染最终页
let dragRenderTimer: number | null = null;
const DRAG_RENDER_DELAY = 120;
// 显示页面加载占位(渲染前调用,渲染后canvas替换)
const showPageLoading = (pageNum: number) => {
if (isLoading.value) return; // 全局加载遮罩已显示,不重复显示页面级占位
const pageContainer = document.getElementById(`page-container-${pageNum}`);
if (!pageContainer) return;
if (pageContainer.querySelector('canvas')) return; // 已渲染
if (pageContainer.querySelector('.page-loading')) return; // 已有占位
const loadingEl = document.createElement('div');
loadingEl.className = 'page-loading';
loadingEl.innerHTML = `<div class="page-loading-spinner"></div><span>正在加载第 ${pageNum} 页...</span>`;
pageContainer.appendChild(loadingEl);
};
// 正在渲染中的页面集合,防止重复渲染
const renderingPages = new Set<number>();
// 元素层重建节流定时器
let addElementsTimer: number | null = null;
// 节流调用 addPdfElementsLayer,避免滚动时频繁重建DOM
const scheduleAddElements = () => {
if (addElementsTimer !== null) return;
addElementsTimer = window.setTimeout(() => {
addElementsTimer = null;
addPdfElementsLayer();
}, 150);
};
// 加载PDF
const loadPdf = async (file: File) => {
try {
isLoading.value = true;
isRenderingPages.value = true;
// 清空之前的内容
if (pdfCanvasContainer.value) {
pdfCanvasContainer.value.innerHTML = "";
}
renderedPages.value.clear();
if (!file || !(file instanceof File)) {
throw new Error("Invalid PDF file");
}
const loadedPdfDoc = await loadPdfDocument(file);
if (!loadedPdfDoc || typeof loadedPdfDoc.numPages !== "number") {
throw new Error("Failed to load PDF document");
}
pdfDocInstance = loadedPdfDoc;
isPdfLoaded.value = true;
totalPages.value = pdfDocInstance.numPages;
emit("pdfLoaded", loadedPdfDoc);
// 渲染所有页面
await renderAllPages(props.zoomLevel);
// 添加PDF元素层
if (props.pdfElements.length > 0) {
nextTick(() => {
addPdfElementsLayer();
});
}
} catch (error) {
console.error('加载PDF失败:', error);
const errorMessage = error instanceof Error ? error.message : 'PDF文件加载失败,请检查文件格式或尝试其他文件';
// 显示错误信息给用户
if (pdfCanvasContainer.value) {
pdfCanvasContainer.value.innerHTML = `
<div class="error-container">
<i class="fa fa-exclamation-triangle"></i>
<h3>PDF加载失败</h3>
<p>${errorMessage}</p>
<p>请尝试:</p>
<ul>
<li>• 检查PDF文件格式是否正确</li>
<li>• 确保文件未损坏</li>
<li>• 尝试使用较小的PDF文件</li>
<li>• 使用标准PDF阅读器验证文件</li>
</ul>
</div>
`;
}
// 通知父组件错误
emit('error', errorMessage);
} finally {
isRenderingPages.value = false;
isLoading.value = false;
}
};
// 从Uint8Array加载PDF文档
const loadPdfFromUint8 = async (pdfUint8: Uint8Array) => {
try {
isLoading.value = true;
isRenderingPages.value = true;
// 清空之前的内容
if (pdfCanvasContainer.value) {
pdfCanvasContainer.value.innerHTML = "";
}
renderedPages.value.clear();
if (!pdfUint8 || pdfUint8.length === 0) {
throw new Error("Invalid PDF data");
}
const loadedPdfDoc = await loadPdfDocumentApi(pdfUint8);
if (!loadedPdfDoc || typeof loadedPdfDoc.numPages !== "number") {
throw new Error("Failed to load PDF document");
}
pdfDocInstance = loadedPdfDoc;
isPdfLoaded.value = true;
totalPages.value = pdfDocInstance.numPages;
emit("pdfLoaded", loadedPdfDoc);
// 渲染所有页面
await renderAllPages(props.zoomLevel);
// 添加PDF元素层
if (props.pdfElements.length > 0) {
nextTick(() => {
addPdfElementsLayer();
});
}
} catch (error) {
console.error("加载PDF失败:", error);
const errorMessage =
error instanceof Error
? error.message
: "PDF文件加载失败,请检查文件格式或尝试其他文件";
// 通知父组件错误
emit("error", errorMessage);
} finally {
isLoading.value = false;
isRenderingPages.value = false; // 重置
}
};
const renderSinglePage = async (pageNum: number, zoom: number) => {
if (!pdfDocInstance || !pdfCanvasContainer.value) return;
pageNum = Math.floor(Number(pageNum));
if (!Number.isInteger(pageNum) || pageNum < 1 || pageNum > totalPages.value) return;
const pageContainer = document.getElementById(`page-container-${pageNum}`) as HTMLElement | null;
if (!pageContainer) return;
// 渲染前显示加载占位,避免渲染期间空白
showPageLoading(pageNum);
const result = await renderPdfPage(
pdfDocInstance,
pageNum,
pageContainer,
zoom
).catch(error => {
console.error(`渲染页面 ${pageNum} 失败:`, error);
throw new Error(`页面 {pageNum} 渲染失败: {error instanceof Error ? error.message : '未知错误'}`);
});
const { canvas, displayViewport } = result;
pageContainer.style.width = `${displayViewport.width}px`;
pageContainer.style.height = `${displayViewport.height}px`;
pageContainer.style.minWidth = `${displayViewport.width}px`;
pageContainer.style.maxWidth = `${displayViewport.width}px`;
pageContainer.innerHTML = '';
pageContainer.appendChild(canvas);
const pageIndicator = document.createElement('div');
pageIndicator.className = 'page-indicator';
pageIndicator.textContent = `第 ${pageNum} 页`;
pageIndicator.style.zIndex = '100';
pageIndicator.style.position = 'absolute';
pageIndicator.style.bottom = '10px';
pageIndicator.style.right = '10px';
pageContainer.appendChild(pageIndicator);
renderedPages.value.set(pageNum, {
container: pageContainer,
canvas,
viewport: displayViewport,
height: Math.floor(displayViewport.height)
});
if (!pageHeight.value) {
pageHeight.value = Math.floor(displayViewport.height);
pageWidth.value = Math.floor(displayViewport.width);
}
if (props.pdfElements.length > 0) {
scheduleAddElements();
}
}
const waitForPdfReady = async (maxRetries = 50, interval = 50) => {
let attempts = 0;
while (
(!pdfDocInstance || !isPdfLoaded.value || !pdfContainer.value || !pdfCanvasContainer.value) &&
attempts < maxRetries
) {
await new Promise(resolve => setTimeout(resolve, interval));
attempts++;
}
return !!pdfDocInstance && !!isPdfLoaded.value && !!pdfContainer.value && !!pdfCanvasContainer.value;
}
const ensurePageRendered = async (pageNum: number, zoom: number) => {
if (renderedPages.value.has(pageNum)) return;
await renderSinglePage(pageNum, zoom);
}
// 更新所有容器的占位高度(基于真实pageHeight),确保滚动位置计算正确
const updateAllPlaceholderHeights = () => {
if (!pageHeight.value || !pdfDocInstance) return;
const height = pageHeight.value;
for (let pageNum = 1; pageNum <= totalPages.value; pageNum++) {
const el = document.getElementById(`page-container-${pageNum}`);
if (el) {
el.style.height = `${height}px`;
el.style.minHeight = `${height}px`;
}
}
};
// 为未渲染页面容器设置占位高度
const applyPlaceholderHeights = () => {
if (!pageHeight.value || !pdfDocInstance) return;
const placeholderHeight = pageHeight.value;
for (let pageNum = 1; pageNum <= totalPages.value; pageNum++) {
if (renderedPages.value.has(pageNum)) continue;
const el = document.getElementById(`page-container-${pageNum}`);
if (el) {
el.style.height = `${placeholderHeight}px`;
el.style.minHeight = `${placeholderHeight}px`;
}
}
};
const renderVisiblePages = async (zoom: number, awaitAll = false) => {
if (!pdfContainer.value || !pdfDocInstance || !pdfCanvasContainer.value) return;
if (!pageHeight.value && renderedPages.value.size > 0) {
const firstPage = renderedPages.value.get(1);
if (firstPage) {
pageHeight.value = firstPage.height;
pageWidth.value = Math.floor(firstPage.viewport.width);
}
}
// 使用真实pageHeight,若不可用则用估算高度作为兜底
const effectiveHeight = pageHeight.value || Math.floor(ESTIMATED_PAGE_HEIGHT * zoom);
if (!effectiveHeight) return;
const container = pdfContainer.value;
const scrollTop = container.scrollTop;
const containerHeight = container.clientHeight;
const pageFullHeight = effectiveHeight + PAGE_GAP;
const firstVisible = Math.max(1, Math.floor(scrollTop / pageFullHeight) + 1);
const lastVisible = Math.min(
totalPages.value,
Math.ceil((scrollTop + containerHeight) / pageFullHeight)
);
const buffer = 3;
const startPage = Math.max(1, firstVisible - buffer);
const endPage = Math.min(totalPages.value, lastVisible + buffer);
// 渲染可见范围内的未渲染页面(跳过正在渲染中的)
for (let pageNum = startPage; pageNum <= endPage; pageNum++) {
if (!renderedPages.value.has(pageNum) && !renderingPages.has(pageNum)) {
renderingPages.add(pageNum);
if (awaitAll) {
try {
await renderSinglePage(pageNum, zoom);
} finally {
renderingPages.delete(pageNum);
}
} else {
void renderSinglePage(pageNum, zoom).finally(() => renderingPages.delete(pageNum));
}
}
}
// 回收远离视口的已渲染页面,释放canvas内存(避免大PDF累积卡死)
const recycleBuffer = buffer + 5;
const recycleStart = Math.max(1, firstVisible - recycleBuffer);
const recycleEnd = Math.min(totalPages.value, lastVisible + recycleBuffer);
const pagesToRecycle: number\[\] = \[\];
renderedPages.value.forEach((info, pageNum) => {
if (pageNum < recycleStart || pageNum > recycleEnd) {
pagesToRecycle.push(pageNum);
}
});
pagesToRecycle.forEach((pageNum) => {
const info = renderedPages.value.get(pageNum);
if (!info) return;
const canvas = info.canvas;
if (canvas) {
const ctx = canvas.getContext('2d');
if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
canvas.width = 0;
canvas.height = 0;
}
if (info.container) {
info.container.innerHTML = '';
// 恢复占位高度,确保滚动位置不塌陷
// 优先级:pageHeight > 记录的页面高度 > 估算高度
const ph = pageHeight.value || info.height || Math.floor(ESTIMATED_PAGE_HEIGHT * props.zoomLevel);
if (ph > 0) {
info.container.style.height = `${ph}px`;
info.container.style.minHeight = `${ph}px`;
}
}
renderedPages.value.delete(pageNum);
});
}
// 估算页面初始高度(用于占位,防止容器高度为0导致滚动计算错误)
const ESTIMATED_PAGE_HEIGHT = 800;
const PAGE_GAP = 8;
// 渲染页面容器并预渲染首屏页面
const renderAllPages = async (zoom: number) => {
if (!pdfDocInstance || !pdfContainer.value) return;
const container = pdfCanvasContainer.value;
if (!container) return;
// 清空容器
container.innerHTML = '';
renderedPages.value.clear();
renderingPages.clear();
pageHeight.value = 0;
pageWidth.value = 0;
lastScrollTop = undefined; // 重置滚动位置缓存
if (dragRenderTimer !== null) {
window.clearTimeout(dragRenderTimer);
dragRenderTimer = null;
}
try {
// 确保PDF文档有效
if (!pdfDocInstance || typeof pdfDocInstance.numPages !== 'number' || pdfDocInstance.numPages <= 0) {
throw new Error('无效的PDF文档');
}
// 创建所有页面容器,立即设置估算占位高度,确保滚动条从一开始就是正确尺寸
const estimatedHeight = ESTIMATED_PAGE_HEIGHT * zoom;
for (let pageNum = 1; pageNum <= pdfDocInstance.numPages; pageNum++) {
const pageContainer = document.createElement("div");
pageContainer.id = `page-container-${pageNum}`;
pageContainer.className = "pdf-page";
pageContainer.style.marginBottom = "0.5rem";
pageContainer.style.height = `${estimatedHeight}px`;
pageContainer.style.minHeight = `${estimatedHeight}px`;
container.appendChild(pageContainer);
}
await renderSinglePage(1, zoom);
// 首页渲染后获取真实pageHeight,更新所有占位容器的高度
if (pageHeight.value > 0) {
updateAllPlaceholderHeights();
}
await renderVisiblePages(zoom, true);
} catch (error) {
console.error("渲染页面时出错:", error);
throw error;
}
};
// 添加PDF元素层
const addPdfElementsLayer = () => {
if (!pdfDocInstance) return;
// 先移除所有现有的元素
document
.querySelectorAll(".pdf-element")
.forEach((element) => element.remove());
props.pdfElements.forEach((element) => {
const pageContainer = document.getElementById(`page-container-${element.page}`);
if (!pageContainer) return;
// 获取页面的viewport信息
const pageRenderInfo = renderedPages.value.get(element.page);
if (!pageRenderInfo) return;
const elementContainer = document.createElement('div');
elementContainer.id = element.id;
elementContainer.className = 'pdf-element';
elementContainer.dataset.elementType = element.type;
elementContainer.dataset.elementContent = element.content;
// 设置位置和大小
const bbox = element.bbox;
if (!bbox || bbox.length !== 4) return;
// 使用与preview.html相同的缩放逻辑
const zoom = props.zoomLevel || 1;
const left = bbox0 * zoom;
const top = bbox1 * zoom;
const width = (bbox2 - bbox0) * zoom;
const height = (bbox3 - bbox1) * zoom;
elementContainer.style.left = `${left}px`;
elementContainer.style.top = `${top}px`;
elementContainer.style.width = `${width}px`;
elementContainer.style.height = `${height}px`;
if (currentHighlightedElementId.value === element.id) {
elementContainer.classList.add('highlighted');
}
// 添加事件监听器 - 使用全局函数保持兼容性
elementContainer.addEventListener('mouseenter', () => {
handlePdfElementHover(elementContainer);
});
elementContainer.addEventListener('mouseleave', () => {
removeTypeLabel(elementContainer);
});
elementContainer.addEventListener('click', () => {
handleElementClick(element.id);
});
pageContainer.appendChild(elementContainer);
});
};
// 处理元素点击
const handleElementClick = (elementId: string) => {
// 高亮当前元素
highlightPdfElement(elementId);
// 发送点击事件
emit("elementClicked", elementId);
};
// 高亮PDF元素
const highlightPdfElement = (elementId: string, scrollIntoView = false) => {
currentHighlightedElementId.value = null;
// 移除其他元素的高亮
document.querySelectorAll(".pdf-element.highlighted").forEach((el) => {
el.classList.remove("highlighted");
removeTypeLabel(el as HTMLElement);
});
// 高亮当前元素
const element = document.getElementById(elementId) as HTMLElement | null;
if (!element) return;
currentHighlightedElementId.value = elementId;
element.classList.add('highlighted');
if (scrollIntoView && pdfContainer.value) {
const container = pdfContainer.value;
const elementRect = element.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const currentScrollTop = container.scrollTop;
const offsetWithinContainer = elementRect.top - containerRect.top + currentScrollTop;
const targetScrollTop =
offsetWithinContainer - containerRect.height / 2 + elementRect.height / 2;
const maxScrollTop = Math.max(0, container.scrollHeight - containerRect.height);
const safeScrollTop = Math.max(0, Math.min(targetScrollTop, maxScrollTop));
requestAnimationFrame(() => {
container.scrollTop = safeScrollTop;
lastScrollTop = safeScrollTop; // 防止程序滚动触发跳变检测
});
}
};
// 计算当前页码和页面内滚动进度(0=页面顶部,1=页面底部)
// 使用数学计算代替遍历所有DOM,避免N次强制回流
const computePageProgress = (scrollTop: number, clientHeight: number) => {
if (totalPages.value === 0) {
return { page: currentPage.value || 1, progress: 0 };
}
// 使用真实pageHeight,若不可用则用估算高度作为兜底
const effectiveHeight = pageHeight.value || Math.floor(ESTIMATED_PAGE_HEIGHT * props.zoomLevel);
if (!effectiveHeight) {
return { page: currentPage.value || 1, progress: 0 };
}
const pageGap = PAGE_GAP;
const pageFullHeight = effectiveHeight + pageGap;
const containerMiddle = scrollTop + clientHeight / 2;
const pageIndex = Math.floor(containerMiddle / pageFullHeight);
const page = Math.max(1, Math.min(totalPages.value, pageIndex + 1));
const pageTop = (page - 1) * pageFullHeight;
const progress = effectiveHeight > 0
? Math.max(0, Math.min(1, (containerMiddle - pageTop) / effectiveHeight))
: 0;
return { page, progress };
};
// 根据当前页码优先同步渲染当前页,再后台渲染可见区域,避免拖滚动条出现空白
const renderPagesByCurrentPage = async (targetPage: number, zoom: number) => {
if (!pdfDocInstance || !pdfContainer.value) return;
if (targetPage < 1 || targetPage > totalPages.value) return;
// 1. 优先同步渲染当前页(await),确保用户立即看到内容而非空白
if (!renderedPages.value.has(targetPage) && !renderingPages.has(targetPage)) {
renderingPages.add(targetPage);
try {
await renderSinglePage(targetPage, zoom);
} catch (e) {
console.error(`优先渲染当前页 ${targetPage} 失败:`, e);
} finally {
renderingPages.delete(targetPage);
}
}
// 2. 后台渲染可见区域及缓冲区页面(含回收逻辑)
void renderVisiblePages(zoom, false);
};
// 处理PDF滚动(使用rAF节流,避免频繁触发)
const handlePdfScroll = () => {
if (!pdfContainer.value) return;
const { scrollTop, scrollHeight, clientHeight } = pdfContainer.value;
// 检测滚动跳变(如拖动滚动条),立即渲染目标页而不等待rAF
const effectiveHeight = pageHeight.value || Math.floor(ESTIMATED_PAGE_HEIGHT * props.zoomLevel);
if (effectiveHeight > 0 && lastScrollTop !== undefined) {
const jumpThreshold = effectiveHeight * 1.5; // 超过1.5页视为跳变
if (Math.abs(scrollTop - lastScrollTop) > jumpThreshold) {
// 立即计算并渲染目标页
const { page, progress } = computePageProgress(scrollTop, clientHeight);
if (page !== currentPage.value) {
currentPage.value = page;
emit("pageChanged", page);
}
emit("scroll", {
scrollTop,
scrollHeight,
clientHeight,
currentPage: page,
pageProgress: progress,
});
// 跳变时立即显示加载占位,防抖渲染目标页(拖动期间不渲染中间页)
showPageLoading(page);
if (dragRenderTimer !== null) {
window.clearTimeout(dragRenderTimer);
}
dragRenderTimer = window.setTimeout(() => {
dragRenderTimer = null;
void renderPagesByCurrentPage(currentPage.value, props.zoomLevel);
}, DRAG_RENDER_DELAY);
lastScrollTop = scrollTop;
return;
}
}
lastScrollTop = scrollTop;
pendingScrollInfo = { scrollTop, scrollHeight, clientHeight };
if (scrollSyncFrame !== null) return;
scrollSyncFrame = window.requestAnimationFrame(() => {
scrollSyncFrame = null;
const info = pendingScrollInfo;
pendingScrollInfo = null;
if (!info) return;
// 计算当前页码和页面进度
const { page, progress } = computePageProgress(info.scrollTop, info.clientHeight);
const pageChanged = page !== currentPage.value;
if (pageChanged) {
currentPage.value = page;
emit("pageChanged", page);
}
// 发送包含页码和进度的滚动信息,支持父组件连续同步
emit("scroll", {
scrollTop: info.scrollTop,
scrollHeight: info.scrollHeight,
clientHeight: info.clientHeight,
currentPage: page,
pageProgress: progress,
});
// 页码变化时防抖渲染当前页(避免快速滚动时渲染中间页),否则常规渲染可见区域
if (pageChanged) {
showPageLoading(page);
if (dragRenderTimer !== null) {
window.clearTimeout(dragRenderTimer);
}
dragRenderTimer = window.setTimeout(() => {
dragRenderTimer = null;
void renderPagesByCurrentPage(currentPage.value, props.zoomLevel);
}, DRAG_RENDER_DELAY);
} else {
renderVisiblePages(props.zoomLevel);
}
});
};
// 监听PDF文件或PDF Uint8Array变化
watch(() =\> props.pdfFile, () =\> props.pdfUint8, async (newFile, newPdfUint8) => {
if (newFile) {
await loadPdf(newFile);
} else if (newPdfUint8 && newPdfUint8.length > 0) {
await loadPdfFromUint8(newPdfUint8);
}
}, { immediate: true, deep: true });
// 监听缩放级别变化
watch(() => props.zoomLevel, (newZoom) => {
if (!pdfDocInstance) return;
// 重置滚动位置缓存,防止缩放后跳变检测误判
lastScrollTop = undefined;
if (zoomRenderTimer !== null) {
window.clearTimeout(zoomRenderTimer);
zoomRenderTimer = null;
}
zoomRenderTimer = window.setTimeout(async () => {
zoomRenderTimer = null;
try {
isRenderingPages.value = true;
await renderAllPages(newZoom);
addPdfElementsLayer();
} catch (error) {
console.error('缩放时渲染页面失败:', error);
} finally {
isRenderingPages.value = false;
}
}, 150);
});
// 监听PDF元素变化
watch(() => props.pdfElements, (newElements) => {
if (
newElements.length > 0 &&
isPdfLoaded.value &&
renderedPages.value.size > 0 &&
!isLoading.value &&
!isRenderingPages.value
) {
nextTick(() => {
addPdfElementsLayer();
});
}
}, { deep: true });
// 缩放功能
const zoomIn = () => { // 放大
const newZoom = Math.min(props.zoomLevel + 0.05, 3.0);
emit("update:zoomLevel", newZoom);
};
// 缩小
const zoomOut = () => {
const newZoom = Math.max(props.zoomLevel - 0.05, 0.5);
emit("update:zoomLevel", newZoom);
};
// 重置
const resetZoom = () => {
emit("update:zoomLevel", 1.2);
};
// 滚动到指定页面
const scrollToPage = (pageNum: number) => {
if (!pdfContainer.value) return;
const container = pdfContainer.value;
const pageElement = document.getElementById(`page-container-${pageNum}`) as HTMLElement | null;
let targetScrollTop: number;
if (pageElement) {
targetScrollTop = pageElement.offsetTop;
} else {
// 估算模式下的回退计算
const effectiveHeight = pageHeight.value || Math.floor(ESTIMATED_PAGE_HEIGHT * props.zoomLevel);
targetScrollTop = (pageNum - 1) * (effectiveHeight + PAGE_GAP);
}
container.scrollTop = targetScrollTop;
// 更新lastScrollTop,防止程序滚动触发跳变检测
lastScrollTop = targetScrollTop;
};
// 滚动到指定元素并高亮
const scrollToPdfElement = async (elementId: string) => {
const ready = await waitForPdfReady();
if (!ready || !pdfContainer.value) return;
let targetPage: number | null = null;
const pdfElement = props.pdfElements.find(element => element.id === elementId);
if (pdfElement) {
targetPage = pdfElement.page;
} else {
const match = elementId.match(/pdf-element-(\d+)-(\d+)/);
if (match) {
targetPage = Number.parseInt(match1, 10);
}
}
if (!targetPage || targetPage < 1 || targetPage > totalPages.value) return;
const wasRendered = renderedPages.value.has(targetPage);
await ensurePageRendered(targetPage, props.zoomLevel);
addPdfElementsLayer();
// 确保元素层已创建
let overlay = document.getElementById(elementId);
if (!overlay) {
for (let i = 0; i < 10 && !overlay; i++) {
await new Promise(resolve => setTimeout(resolve, 20));
addPdfElementsLayer();
overlay = document.getElementById(elementId);
}
}
scrollToPage(targetPage);
await nextTick();
highlightPdfElement(elementId, true);
if (!wasRendered) {
setTimeout(async () => {
if (!pdfContainer.value) return;
await nextTick();
highlightPdfElement(elementId, true);
}, 400);
}
}
// 跳转到指定页面
const goToPage = async () => {
const target = Math.floor(Number(jumpPage.value));
if (Number.isInteger(target) && target >= 1 && target <= totalPages.value) {
// 确保所有容器高度已同步(offsetTop计算依赖正确的高度)
updateAllPlaceholderHeights();
// 先渲染目标页,避免滚动后显示空白
await ensurePageRendered(target, props.zoomLevel);
scrollToPage(target);
await nextTick();
// 渲染目标页附近可见页面,跟随当前页加载
void renderVisiblePages(props.zoomLevel);
// 更新当前页码显示
currentPage.value = target;
jumpPage.value = target;
}
};
// 暴露方法给父组件
defineExpose({
scrollToPage,
highlightPdfElement,
renderAllPages,
scrollToPdfElement
});
// 组件挂载
onMounted(() => {
pdfCanvasContainer.value = document.getElementById('pdf-canvas-container');
});
// 组件卸载
onUnmounted(() => {
// 清理资源
pdfDocInstance = null;
isPdfLoaded.value = false;
renderedPages.value.clear();
renderingPages.clear();
lastScrollTop = undefined;
// 清理待执行的滚动同步动画帧
if (scrollSyncFrame !== null) {
window.cancelAnimationFrame(scrollSyncFrame);
scrollSyncFrame = null;
}
pendingScrollInfo = null;
// 清理缩放渲染定时器
if (zoomRenderTimer !== null) {
window.clearTimeout(zoomRenderTimer);
zoomRenderTimer = null;
}
// 清理元素层重建节流定时器
if (addElementsTimer !== null) {
window.clearTimeout(addElementsTimer);
addElementsTimer = null;
}
// 清理拖滚动条防抖定时器
if (dragRenderTimer !== null) {
window.clearTimeout(dragRenderTimer);
dragRenderTimer = null;
}
});
</script>
<style lang="scss" scoped>
.pdf-viewer {
position: relative;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
:deep(.pdf-page) {
margin-bottom: $spacing-sm !important;
position: relative;
}
/* 页码指示器样式 */
:deep(.pdf-page divclass\*="absolute":not(.type-label)) {
display: block !important;
position: absolute !important;
right: $spacing-sm !important;
bottom: $spacing-sm !important;
z-index: 100 !important;
opacity: 1 !important;
visibility: visible !important;
}
.pdf-container {
flex: 1;
min-height: 0; // 允许 flex 子项收缩
text-align: center;
background-color: $bg-secondary;
position: relative;
overflow-x: hidden;
overflow-y: scroll;
width: 100%;
@include scrollbar();
}
.pdf-canvas-container {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
min-width: 0;
overflow: hidden;
padding: 0;
margin: 0;
}
.pdf-page {
background: $bg-primary;
box-shadow: $shadow-md;
margin-bottom: $spacing-sm !important;
position: relative;
border-radius: $radius-sm;
}
.page-indicator {
background-color: rgba(255, 255, 255, 0.9);
color: $text-secondary;
border: 1px solid $border-color;
padding: 2px 8px;
border-radius: $radius-sm;
font-size: $font-size-sm;
font-weight: 500;
box-shadow: $shadow-md;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
bottom: 10px;
right: 10px;
z-index: 100;
}
:deep(.page-loading) {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: #999;
font-size: 13px;
z-index: 50;
white-space: nowrap;
}
:deep(.page-loading-spinner) {
width: 32px;
height: 32px;
border: 3px solid #e0e0e0;
border-top-color: $bg-color;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
/* PDF元素样式 */
:deep(.pdf-element) {
cursor: pointer;
@include transition(all);
position: absolute;
white-space: pre-wrap;
word-wrap: break-word;
pointer-events: auto;
background-color: transparent;
outline: none;
z-index: 1;
border: 1px solid rgba(16, 185, 129, 0.6) !important; /* 使用浅绿色边框 */
}
:deep(.pdf-element:hover) {
background-color: rgba(16, 185, 129, 0.3) !important; /* 使用浅绿色悬停底色 */
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.7) !important; /* 使用浅绿色悬停边框 */
z-index: 1000 !important;
border-radius: 4px !important; /* 添加圆角效果 */
}
:deep(.pdf-element.highlighted) {
background-color: rgba(16, 185, 129, 0.25) !important; /* 使用浅绿色底色 */
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.8) !important; /* 使用浅绿色边框 */
z-index: 1000 !important;
transition: all 0.2s ease !important; /* 添加平滑过渡效果 */
border-radius: 4px !important; /* 添加圆角效果 */
}
:deep(.pdf-element-hovered) {
background-color: rgba(16, 185, 129, 0.2) !important; /* 使用浅绿色悬停底色 */
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.6) !important; /* 使用浅绿色悬停边框 */
z-index: 1000 !important;
pointer-events: auto !important;
cursor: pointer !important;
position: absolute !important;
transition: all 0.2s ease !important;
border-radius: 4px !important; /* 添加圆角效果,与高亮状态保持一致 */
}
/* 类型标签样式 - 优化为与浅绿色高亮协调的现代风格 */
:deep(.type-label) {
position: absolute !important;
top: -28px !important;
left: 0 !important;
background-color: #10b981 !important; /* 使用与高亮协调的浅绿色 */
color: white !important;
padding: 4px 10px !important;
border-radius: 4px 4px 4px 0 !important; /* 左侧无圆角,与元素更融合 */
font-size: 12px !important;
font-weight: 600 !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2) !important; /* 增强阴影使标签更突出 */
z-index: 9999 !important;
pointer-events: none !important;
opacity: 1 !important;
transition: all 0.2s ease !important;
white-space: nowrap !important;
text-transform: capitalize !important;
letter-spacing: 0.5px !important;
border: 1px solid rgba(255, 255, 255, 0.3) !important; /* 添加浅色边框增强立体感 */
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.8);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 10000;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid #f3f3f3;
border-top: 3px solid $bg-color;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: $spacing-md;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
height: 60px;
background: #ffffff;
box-shadow: 0px -1px 10px rgba(0, 0, 40, 0.1);
z-index: 1;
}
.left-toolbar {
display: flex;
align-items: center;
margin-left: 16px;
}
.toolbar-btn {
width: 38px;
height: 36px;
border-radius: 6px 0px 0px 6px;
border: 1px solid #e2e5eb;
color: $font-size-primary;
font-size: 13px;
display: flex;
align-items: center;
justify-content: center;
&:hover {
color: $bg-color;
}
}
.icon-fangda,
.icon-suoxiao {
font-size: 14px;
}
.toolbar-right {
border-radius: 0px 6px 6px 0px;
}
.zoom-level {
padding: 0 10px;
height: 34px;
border: 1px solid #e2e5eb;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
color: $font-size-primary;
border-left: none;
border-right: none;
}
.toolbar-reset {
padding: 0 16px;
height: 36px;
border-radius: 6px;
border: 1px solid #e2e5eb;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 13px;
margin-left: 16px;
color: $font-size-primary;
.icon-shuaxin {
margin-right: 5px;
font-size: 14px;
}
&:hover {
border-color: $color-primary;
color: $color-primary;
}
}
.page-navigation {
display: flex;
align-items: center;
.page-navigation-text {
color: $font-size-primary;
font-size: 13px;
}
.page-jump-text {
margin-left: 20px;
margin-right: 9px;
}
.page-input {
width: 60px;
}
.jump-btn {
margin-right: 16px;
margin-left: 10px;
height: 36px;
border: 1px solid #e2e5eb;
color: $font-size-primary;
font-size: 13px;
&:hover {
color: $bg-color;
}
}
}
:deep(inputtype="number") {
-moz-appearance: textfield;
text-align: center;
}
:deep(inputtype="number"::-webkit-outer-spin-button),
:deep(inputtype="number"::-webkit-inner-spin-button) {
-webkit-appearance: none;
margin: 0;
}
@mixin scrollbar-thin() {
scrollbar-width: thin;
&::-webkit-scrollbar {
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-track {
background: $bg-secondary;
}
&::-webkit-scrollbar-thumb {
background: $color-gray-400;
border-radius: $radius-sm;
}
&::-webkit-scrollbar-thumb:hover {
background: $color-gray-500;
}
}
.error-container {
background-color: rgba($color-error, 0.1);
border: 1px solid $color-error;
border-radius: $radius-md;
padding: $spacing-lg;
margin: $spacing-lg;
}
.error-container i {
color: $color-error;
}
.error-container h3 {
color: $color-error;
@include font($font-size-lg, 600);
margin-bottom: $spacing-sm;
}
.error-container p {
color: $color-error;
margin-bottom: $spacing-md;
}
.error-container ul {
text-align: left;
color: $color-error;
}
.error-container li {
margin-bottom: $spacing-2xs;
}
</style>
MarkdownRenderer组件代码:
<template>
<div class="markdown-renderer">
<!-- Markdown容器 -->
<div
id="md-container"
ref="mdContainer"
class="md-container"
@scroll="handleMdScroll"
>
<!-- HTML输出区域 -->
<div
id="md-output"
ref="mdOutput"
class="md-output markdown-body max-w-none"
v-html="renderedContent"
></div>
<!-- 加载状态 -->
<div v-if="isLoading" class="loading-overlay">
<div class="loading-spinner"></div>
<p>正在渲染内容...</p>
</div>
</div>
<!-- 工具栏 -->
<div class="md-toolbar">
<el-button
@click="downloadContent"
class="toolbar-btn"
:disabled="!markdownContent"
>
<span class="icon iconfont icon-xiazai"></span> 下载
</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import hljs from 'highlight.js';
import 'highlight.js/styles/default.css';
import type { MarkdownElement } from './../../../types';
// 导入marked库
import { marked } from 'marked';
// Props
interface Props {
markdownContent: string;
isSyncScroll: boolean;
fileName?: string;
}
const props = withDefaults(defineProps<Props>(), {
markdownContent: '',
isSyncScroll: true,
fileName: 'document'
});
// Emits
interface Emits {
(e: 'elementClicked', elementId: string): void;
(e: 'scroll', scrollInfo: { scrollTop: number; scrollHeight: number; clientHeight: number }): void;
(e: 'elementsCreated', elements: MarkdownElement\[\]): void;
}
const emit = defineEmits<Emits>();
const mdContainer = ref<HTMLElement>();
const mdOutput = ref<HTMLElement>();
const isLoading = ref(false);
const mdElements = ref<MarkdownElement\[\]>(\[\]);
const elementCounter = ref(0);
// 渲染后的HTML内容
const renderedContent = ref<string>('');
// 滚动同步rAF节流状态
let scrollSyncFrame: number | null = null;
let pendingScrollInfo: { scrollTop: number; scrollHeight: number; clientHeight: number } | null = null;
// 异步渲染Markdown内容
const renderMarkdown = async () => {
if (!props.markdownContent) {
renderedContent.value = '';
return;
}
try {
// 使用marked将Markdown转换为HTML
isLoading.value = true;
const chunks = props.markdownContent.split(/\n\s*(?=<!-- page \d+ -->)/);
let htmlContent = '';
for (const chunk of chunks) {
htmlContent += await marked(chunk);
}
// 处理页码标记(保持向后兼容)
const processedContent = processPageNumbers(htmlContent);
renderedContent.value = processedContent;
// 更新后创建元素和应用语法高亮
await nextTick();
createMdElements();
applySyntaxHighlighting();
} catch (error) {
console.error('渲染HTML失败:', error);
renderedContent.value = `<p class="render-error">渲染失败: ${error}</p>`;
} finally {
isLoading.value = false;
}
}
// 监听Markdown内容变化
watch(() => props.markdownContent, async () => {
await renderMarkdown();
}, { immediate: true });
// 创建HTML元素映射
const createMdElements = () => {
if (!mdOutput.value) {
console.error('createMdElements: mdOutput is not available');
return;
}
try {
mdElements.value = \[\];
elementCounter.value = 0;
// 获取所有可点击的元素,每个元素对应JSON中的一个对象,不包括表格内的td和th
const elements = mdOutput.value.querySelectorAll('h1, h2, h3, h4, h5, h6, p, li, table, pre, blockquote') as NodeListOf<HTMLElement>;
// 提取所有页面元素索引注释
const elementIndexMap: Map<Element, {page: number, index: number, pdfId: string, fullIndex: number, globalIndex: number}> = new Map();
// 使用TreeWalker遍历所有节点,包括注释节点
const treeWalker = document.createTreeWalker(
mdOutput.value,
NodeFilter.SHOW_COMMENT,
null
);
let currentNode = treeWalker.nextNode();
let commentCount = 0;
while (currentNode) {
const commentText = currentNode.textContent || '';
// 优先匹配包含globalIndex的注释格式
const globalIndexMatch = commentText.match(/page:(\d+)\s+element:(\d+)\s+id:(\\w-+)\s+globalIndex:(\d+)/);
if (globalIndexMatch) {
const page = parseInt(globalIndexMatch1, 10);
const index = parseInt(globalIndexMatch2, 10);
const pdfId = globalIndexMatch3;
const globalIndex = parseInt(globalIndexMatch4, 10);
// 将注释位置与后续元素关联
let nextElement = (currentNode as Element).nextElementSibling;
while (nextElement && nextElement.nodeType === 1) {
if ('H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'P', 'LI', 'TABLE', 'PRE', 'BLOCKQUOTE'.includes(nextElement.tagName)) {
elementIndexMap.set(nextElement, {page, index, pdfId, fullIndex: 0, globalIndex});
break;
}
nextElement = nextElement.nextElementSibling;
}
commentCount++;
} else {
// 匹配其他注释格式(保持向后兼容)
const pageIndexMatch = commentText.match(/page:(\d+)\s+element:(\d+)\s+id:(\\w-+)(?:\s+fullIndex:(\d+))?(?:\s+globalIndex:(\d+))?/);
if (pageIndexMatch) {
const page = parseInt(pageIndexMatch1, 10);
const index = parseInt(pageIndexMatch2, 10);
const pdfId = pageIndexMatch3;
const fullIndex = pageIndexMatch4 ? parseInt(pageIndexMatch4, 10) : 0;
const globalIndex = pageIndexMatch5 ? parseInt(pageIndexMatch5, 10) : 0;
// 将注释位置与后续元素关联
let nextElement = (currentNode as Element).nextElementSibling;
while (nextElement && nextElement.nodeType === 1) {
if ('H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'P', 'LI', 'TABLE', 'PRE', 'BLOCKQUOTE'.includes(nextElement.tagName)) {
elementIndexMap.set(nextElement, {page, index, pdfId, fullIndex, globalIndex});
break;
}
nextElement = nextElement.nextElementSibling;
}
commentCount++;
}
}
currentNode = treeWalker.nextNode();
}
elements.forEach((element: HTMLElement) => {
const elementId = `md-element-${elementCounter.value++}`;
element.dataset.elementId = elementId;
element.classList.add('md-element');
// 确定元素类型
let type = 'text';
if (element.tagName === 'H1') type = 'title';
else if (element.tagName === 'H2') type = 'subtitle';
else if ('H3', 'H4', 'H5', 'H6'.includes(element.tagName)) type = 'heading';
else if (element.tagName === 'TABLE') type = 'table';
else if (element.tagName === 'PRE') type = 'code';
else if (element.tagName === 'BLOCKQUOTE') type = 'quote';
else if (element.tagName === 'LI') type = 'list';
// 获取页面和元素索引信息
const indexInfo = elementIndexMap.get(element);
// 创建元素映射
const mdElement: MarkdownElement = {
id: elementId,
type: type,
content: element.textContent || '',
element: element,
used: false,
page: indexInfo?.page, // 添加页面索引
elementIndex: indexInfo?.index, // 添加页面内元素索引
fullIndex: indexInfo?.fullIndex, // 添加全文索引
globalIndex: indexInfo?.globalIndex, // 添加全局索引,用于确保严格的映射顺序
estimatedPage: indexInfo?.page,
mappedPdfId: indexInfo?.pdfId
};
mdElements.value.push(mdElement);
// 添加点击事件
element.addEventListener('click', handleElementClick);
// 表格不显示复制按钮,其他元素添加复制按钮交互
if (type !== 'table') {
element.addEventListener('mouseenter', () => {
createCopyButton(element);
});
element.addEventListener('mouseleave', () => {
removeCopyButton(element);
});
}
// 为表格添加特殊处理
if (type === 'table') {
element.style.cursor = 'pointer';
// 为表格内部元素也添加点击事件
(element.querySelectorAll('th, td, tr') as NodeListOf<HTMLElement>).forEach(child => {
child.style.cursor = 'pointer';
child.addEventListener('click', (e) => {
e.stopPropagation();
handleElementClick({ target: element } as unknown as MouseEvent);
});
});
} else {
element.style.cursor = 'pointer';
}
});
// 发送元素创建事件
emit('elementsCreated', mdElements.value);
} catch (error) {
console.error('createMdElements: Error creating HTML element mappings:', error);
}
}
// 处理元素点击
const handleElementClick = (event: MouseEvent) => {
const target = event.target as HTMLElement;
const elementId = target.dataset.elementId || (target.closest('data-element-id') as HTMLElement | null)?.dataset.elementId;
if (elementId) {
// 高亮当前元素
highlightMdElement(elementId);
// 发送点击事件
emit('elementClicked', elementId);
}
}
// 高亮Markdown元素
const highlightMdElement = (elementId: string) => {
// 增加参数验证
if (!elementId) {
console.error('highlightMdElement: elementId is required');
return;
}
try {
// 移除其他元素的高亮
const highlightedElements = document.querySelectorAll('.md-element.highlighted') as NodeListOf<HTMLElement>;
highlightedElements.forEach(el => {
el.classList.remove('highlighted');
el.style.backgroundColor = '';
el.style.boxShadow = '';
el.style.border = '';
el.style.transform = '';
});
// 高亮当前元素
const element = document.querySelector(`data-element-id="${elementId}"`) as HTMLElement;
if (!element) {
console.error(`highlightMdElement: Element with id ${elementId} not found`);
return;
}
element.classList.add('highlighted');
// 添加类名
if (element.tagName === 'TABLE') {
// 表格特殊处理 - SCSS中已定义样式
}
} catch (error) {
console.error('highlightMdElement: Error highlighting element:', error);
}
}
// 应用语法高亮
const applySyntaxHighlighting = () => {
if (!mdOutput.value) return;
try {
// 高亮所有代码块
mdOutput.value.querySelectorAll('pre code').forEach((block) => {
hljs.highlightElement(block as HTMLElement);
});
} catch (error) {
console.error('语法高亮失败:', error);
}
}
// 处理页码标记
const processPageNumbers = (content: string): string => {
const pageNumberRegex = /<!-- page (\d+) -->/g;
return content.replace(pageNumberRegex, (_, pageNum) => {
return `<div class="markdown-page-number"><span class="page-line"></span><span class="page-text">第 ${pageNum} 页</span><span class="page-line"></span></div>`;
});
}
// 处理Markdown滚动(使用rAF节流,避免频繁触发)
const handleMdScroll = () => {
if (!mdContainer.value) return;
const { scrollTop, scrollHeight, clientHeight } = mdContainer.value;
pendingScrollInfo = { scrollTop, scrollHeight, clientHeight };
if (scrollSyncFrame !== null) return;
scrollSyncFrame = window.requestAnimationFrame(() => {
scrollSyncFrame = null;
const info = pendingScrollInfo;
pendingScrollInfo = null;
if (!info) return;
emit('scroll', {
scrollTop: info.scrollTop,
scrollHeight: info.scrollHeight,
clientHeight: info.clientHeight,
});
});
};
// 下载内容
const downloadContent = () => {
if (!props.markdownContent) return;
// 根据内容判断是HTML还是Markdown
const isHtml = props.markdownContent.includes('<html') || props.markdownContent.includes('<body');
const blobType = isHtml ? 'text/html;charset=utf-8' : 'text/markdown;charset=utf-8';
const fileExt = isHtml ? '.html' : '.md';
const blob = new Blob(props.markdownContent, { type: blobType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `{props.fileName.replace(/\\.\[\^/.\]+/, '')}${fileExt}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast('下载成功', 'success');
}
// 创建复制按钮
const createCopyButton = (element: HTMLElement) => {
// 移除已存在的复制按钮
element.querySelectorAll('.md-copy-btn').forEach(btn => btn.remove());
const copyBtn = document.createElement('div');
copyBtn.className = 'md-copy-btn';
copyBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>`;
copyBtn.title = '复制文本';
// 点击复制
copyBtn.addEventListener('click', async (e) => {
e.stopPropagation();
const content = element.textContent || '';
if (content) {
try {
// 尝试使用现代剪贴板API
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(content);
ElMessage.success('复制成功');
} else {
// 降级方案:使用传统的document.execCommand('copy')
const textArea = document.createElement('textarea');
textArea.value = content;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand('copy');
if (successful) {
ElMessage.success('复制成功');
} else {
throw new Error('execCommand failed');
}
} catch (err) {
// 再次降级:提示用户手动复制
ElMessage.info('请手动复制文本');
} finally {
document.body.removeChild(textArea);
}
}
} catch (err) {
console.error('复制失败:', err);
ElMessage.error('复制失败,请手动复制');
}
}
});
element.appendChild(copyBtn);
};
// 移除复制按钮
const removeCopyButton = (element: HTMLElement) => {
element.querySelectorAll('.md-copy-btn').forEach(btn => {
(btn as HTMLElement).style.opacity = '0';
setTimeout(() => btn.remove(), 200);
});
};
// 显示提示消息
const showToast = (message: string, type: 'success' | 'error' | 'warning' = 'success') => {
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => {
document.body.removeChild(toast);
}, 300);
}, 3000);
}
// 滚动到指定元素(返回Promise,支持await)
const scrollElementInternal = (elementId: string, mode: 'top' | 'center'): Promise<void> => {
return new Promise((resolve) => {
if (!elementId) {
console.error('scrollToElement: elementId is required');
resolve();
return;
}
nextTick(() => {
const element = document.querySelector(`data-element-id="${elementId}"`) as HTMLElement;
if (!element) {
console.error(`scrollToElement: Element with id ${elementId} not found`);
resolve();
return;
}
if (!mdContainer.value || !mdOutput.value) {
console.error('scrollToElement: Container or output element not found');
resolve();
return;
}
try {
const outputStyles = window.getComputedStyle(mdOutput.value);
const mdOutputPaddingBottom = parseInt(outputStyles.paddingBottom, 10) || 0;
const elementRect = element.getBoundingClientRect();
const containerRect = mdContainer.value.getBoundingClientRect();
const elementTopRelativeToContainer = elementRect.top - containerRect.top + mdContainer.value.scrollTop;
let targetScrollTop: number;
if (mode === 'center') {
const containerHeight = containerRect.height;
const elementHeight = elementRect.height;
targetScrollTop = elementTopRelativeToContainer - (containerHeight / 2) + (elementHeight / 2);
} else {
const topOffset = 16;
targetScrollTop = elementTopRelativeToContainer - topOffset;
}
const maxContentScrollHeight = mdOutput.value.scrollHeight - mdOutputPaddingBottom;
const maxScrollTop = Math.max(0, maxContentScrollHeight - containerRect.height);
const safeScrollTop = Math.max(0, Math.min(targetScrollTop, maxScrollTop));
requestAnimationFrame(() => {
if (mdContainer.value) {
mdContainer.value.scrollTop = safeScrollTop;
}
resolve();
});
} catch (error) {
console.error('scrollToElement: Error calculating scroll position:', error);
resolve();
}
});
});
}
// 滚动到指定元素顶部
const scrollToElement = (elementId: string) => {
return scrollElementInternal(elementId, 'top');
}
// 滚动到指定元素居中
const scrollToElementCentered = (elementId: string) => {
return scrollElementInternal(elementId, 'center');
}
// 滚动到指定元素(支持位置参数,供1.vue的scrollToElementWithOverlay调用)
const scrollToElementWithOverlay = (elementId: string, position: 'center' | 'top' = 'center') => {
return scrollElementInternal(elementId, position === 'center' ? 'center' : 'top');
}
// 根据页面进度在两个元素间连续滚动(供1.vue的连续同步使用)
const scrollToPageRangeProgress = (startId: string, endId: string, progress: number) => {
return new Promise<void>((resolve) => {
nextTick(() => {
if (!mdContainer.value || !mdOutput.value) {
resolve();
return;
}
const startElement = document.querySelector(`data-element-id="${startId}"`) as HTMLElement;
const endElement = document.querySelector(`data-element-id="${endId}"`) as HTMLElement;
if (!startElement || !endElement) {
// 回退到起始元素
if (startElement) {
scrollElementInternal(startId, 'top').then(resolve);
} else {
resolve();
}
return;
}
try {
const containerRect = mdContainer.value.getBoundingClientRect();
const startRect = startElement.getBoundingClientRect();
const endRect = endElement.getBoundingClientRect();
const startTop = startRect.top - containerRect.top + mdContainer.value.scrollTop;
const endTop = endRect.top - containerRect.top + mdContainer.value.scrollTop;
const clampedProgress = Math.max(0, Math.min(1, progress));
// 根据进度在起始和结束位置之间插值,并居中显示
const targetTop = startTop + (endTop - startTop) * clampedProgress;
const targetScrollTop = targetTop - containerRect.height / 2;
const outputStyles = window.getComputedStyle(mdOutput.value);
const mdOutputPaddingBottom = parseInt(outputStyles.paddingBottom, 10) || 0;
const maxContentScrollHeight = mdOutput.value.scrollHeight - mdOutputPaddingBottom;
const maxScrollTop = Math.max(0, maxContentScrollHeight - containerRect.height);
const safeScrollTop = Math.max(0, Math.min(targetScrollTop, maxScrollTop));
requestAnimationFrame(() => {
if (mdContainer.value) {
mdContainer.value.scrollTop = safeScrollTop;
}
resolve();
});
} catch (error) {
console.error('scrollToPageRangeProgress: Error:', error);
resolve();
}
});
});
}
// 暴露方法给父组件
defineExpose({
highlightMdElement,
scrollToElement,
scrollToElementCentered,
scrollToElementWithOverlay,
scrollToPageRangeProgress,
getMdElements: () => mdElements.value
});
// 组件挂载
onMounted(() => {
renderMarkdown();
});
// 组件卸载
onUnmounted(() => {
// 清理待执行的滚动同步动画帧
if (scrollSyncFrame !== null) {
window.cancelAnimationFrame(scrollSyncFrame);
scrollSyncFrame = null;
}
pendingScrollInfo = null;
});
</script>
<style lang="scss" scoped>
/* 复制按钮样式 */
:deep(.md-copy-btn) {
position: absolute;
bottom: -30px;
right: 0;
background-color: #10b981;
color: white;
width: 26px;
height: 26px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 9999;
pointer-events: auto;
opacity: 0.9;
transition: all 0.2s ease;
}
/* 确保 md-element 有相对定位,以便复制按钮定位 */
:deep(.md-element) {
position: relative;
}
.markdown-renderer {
position: relative;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.md-container {
flex: 1;
min-height: 0; // 允许 flex 子项收缩
background-color: $bg-primary;
position: relative;
@include scrollbar();
overflow-y: auto;
}
.md-output {
padding: $spacing-lg;
min-height: 100%;
/* 单倍行距模式 */
font-size: $font-size-base !important;
line-height: $line-height-normal !important;
color: $text-primary;
font-family: $font-family-primary;
overflow-wrap: break-word;
word-break: break-word;
}
/* Markdown元素样式 */
:deep(.md-element) {
cursor: pointer;
@include transition(all);
line-height: $line-height-normal;
letter-spacing: $letter-spacing-normal;
word-spacing: $word-spacing-normal;
font-family: $font-family-primary;
color: $text-primary;
}
/* 段落样式 */
:deep(p.md-element) {
margin-bottom: $spacing-sm !important;
text-align: justify;
text-indent: 2em;
font-size: $font-size-base;
}
/* 标题样式 */
:deep(h1.md-element),
:deep(h2.md-element),
:deep(h3.md-element),
:deep(h4.md-element),
:deep(h5.md-element),
:deep(h6.md-element) {
font-weight: 600;
line-height: $line-height-tight;
margin-top: $spacing-lg !important;
margin-bottom: $spacing-md !important;
letter-spacing: $letter-spacing-tight;
color: $text-dark;
}
:deep(h1.md-element) {
font-size: $font-size-lg !important;
border-bottom: 2px solid $border-color;
padding-bottom: $spacing-sm;
}
:deep(h2.md-element) {
font-size: $font-size-base !important;
border-bottom: 1px solid $border-color;
padding-bottom: $spacing-sm;
}
:deep(h3.md-element) {
font-size: $font-size-base !important;
}
/* 段落样式 - 增加段落间距 */
:deep(.md-element p) {
margin-bottom: $spacing-xl !important;
line-height: $line-height-normal;
}
/* 优化列表样式 */
:deep(ul.md-element),
:deep(ol.md-element) {
padding-left: $spacing-lg !important;
margin-bottom: $spacing-md !important;
}
:deep(ul.md-element) {
list-style-type: disc;
}
:deep(ol.md-element) {
list-style-type: decimal;
}
:deep(li.md-element) {
margin-bottom: $spacing-sm !important;
line-height: $line-height-normal;
}
/* 表格样式 */
:deep(table.md-element) {
table-layout: fixed !important;
width: 100% !important;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
white-space: normal !important;
border-collapse: collapse !important;
display: table !important;
margin: $spacing-lg 0 !important;
border: 3px solid #666 !important;
border-radius: $radius-lg;
overflow: hidden;
box-shadow: $shadow-md;
background-color: $bg-primary;
}
:deep(th.md-element),
:deep(td.md-element) {
word-wrap: break-word !important;
overflow-wrap: break-word !important;
white-space: normal !important;
max-width: 200px !important;
padding: $spacing-md !important;
border: 1px solid $border-color !important;
vertical-align: top;
position: relative;
}
:deep(.md-element table th) {
background-color: $bg-secondary !important;
font-weight: 600 !important;
color: $text-dark !important;
border-bottom: 2px solid #999 !important;
border-right: 1px solid #999 !important;
text-align: left;
}
:deep(.md-element table td) {
background-color: $bg-primary !important;
color: $text-primary !important;
border-right: 1px solid #aaa !important;
border-bottom: 1px solid #aaa !important;
}
:deep(.md-element table th:last-child),
:deep(.md-element table td:last-child) {
border-right: none !important;
}
:deep(.md-element table tr:last-child th),
:deep(.md-element table tr:last-child td) {
border-bottom: none !important;
}
/* 确保表格容器不会限制表格宽度 */
:deep(.md-element table),
:deep(.md-element table thead),
:deep(.md-element table tbody),
:deep(.md-element table tr),
:deep(.md-element table th),
:deep(.md-element table td) {
box-sizing: border-box !important;
}
:deep(.md-element th) {
background-color: $bg-tertiary;
font-weight: 600;
}
:deep(.md-element tr:hover td) {
background-color: $bg-hover;
}
/* 代码块样式 */
:deep(.md-element pre) {
border-radius: $radius-lg;
padding: $spacing-md !important;
margin: $spacing-lg 0 !important;
overflow-x: auto;
background-color: $bg-tertiary !important;
border: 1px solid $border-color;
color: $text-primary;
}
:deep(.md-element code) {
font-family: $font-family-mono;
font-size: 0.9em;
border-radius: $radius-sm;
background-color: $bg-secondary;
padding: spacing-xs spacing-sm;
border: 1px solid $border-color;
}
:deep(.md-element pre code) {
background-color: transparent;
border: none;
padding: 0;
color: inherit;
}
/* 引用样式 */
:deep(.md-element blockquote) {
border-left: 4px solid $color-primary;
padding: spacing-md spacing-lg !important;
margin: $spacing-lg 0 !important;
background-color: rgba(color-primary, opacity-10);
border-radius: 0 radius-sm radius-sm 0;
font-style: italic;
color: $text-secondary;
}
/* 链接样式 */
:deep(.md-element a) {
color: $color-primary;
text-decoration: none;
@include transition(all);
border-bottom: 1px solid transparent;
}
:deep(.md-element a:hover) {
color: $color-primary-dark;
border-bottom-color: $color-primary-dark;
}
/* 优化强调样式 */
:deep(.md-element strong) {
font-weight: 600;
color: $text-dark;
}
:deep(.md-element em) {
font-style: italic;
color: $text-primary;
}
/* 表格样式 - 添加边框 */
:deep(.md-element table) {
border-collapse: collapse;
width: 100%;
margin: $spacing-lg 0;
border: 1px solid $border-color-dark;
border-style: solid;
}
:deep(.md-element th),
:deep(.md-element td) {
border: 1px solid $border-color-dark;
border-style: solid;
padding: spacing-md spacing-lg;
text-align: left;
}
:deep(.md-element th) {
background-color: $bg-secondary;
font-weight: 600;
color: $text-primary;
}
/* 页码样式 - 参考preview.html */
:deep(.markdown-page-number) {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 100% !important;
min-width: 100% !important;
max-width: 100% !important;
margin: $spacing-xl 0 !important;
padding: spacing-sm spacing-lg !important;
box-sizing: border-box !important;
background-color: $bg-secondary !important;
border-radius: $radius-md !important;
border: 1px solid $border-color !important;
position: relative !important;
left: 0 !important;
right: 0 !important;
clear: both !important;
overflow: hidden !important;
}
:deep(.markdown-page-number .page-text) {
font-weight: bold !important;
color: $text-secondary !important;
margin: 0 $spacing-md !important;
white-space: nowrap !important;
flex-shrink: 0 !important;
}
:deep(.markdown-page-number .page-line) {
flex-grow: 1 !important;
height: 1px !important;
background: repeating-linear-gradient(
to right,
#666 0px,
#666 8px,
transparent 8px,
transparent 16px
) !important;
min-width: 20px !important;
}
:deep(#md-output .markdown-page-number) {
text-align: center !important;
margin-left: auto !important;
margin-right: auto !important;
}
/* 交互样式 */
:deep(.md-element:hover) {
background-color: rgba($color-accent, 0.1);
}
:deep(.md-element.highlighted) {
background-color: rgba($color-accent, 0.2);
box-shadow: 0 0 0 2px rgba($color-accent, 0.4);
border-radius: $radius-sm;
}
/* 优化表格高亮效果 */
:deep(.md-element.highlighted table),
:deep(table.md-element.highlighted) {
background-color: rgba($color-accent, 0.1) !important;
box-shadow: 0 0 0 2px rgba($color-accent, 0.5) !important;
border-radius: $radius-md !important;
border: 1px solid rgba($color-accent, 0.7) !important;
transform: scale(1.005) !important;
@include transition(all);
z-index: 10 !important;
}
/* 表格高亮时的单元格样式 */
:deep(.md-element.highlighted td),
:deep(.md-element.highlighted th),
:deep(table.md-element.highlighted td),
:deep(table.md-element.highlighted th) {
background-color: rgba($color-accent, 0.05) !important;
border-color: rgba($color-accent, 0.5) !important;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba($bg-primary, 0.8);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 10000;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid $bg-secondary;
border-top: 4px solid $color-primary;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: $spacing-xl;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.md-toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
height: 60px;
background: #FFFFFF;
box-shadow: 0px -1px 10px rgba(0, 0, 40, 0.1);
padding-right: 16px;
z-index: 1;
}
.toolbar-btn {
padding: 0 10px;
height: 36px;
border-radius: 6px;
border: 1px solid #e2e5eb;
color: $font-size-primary;
font-size: 13px;
display: flex;
align-items: center;
justify-content: center;
&:hover {
color: $bg-color;
}
.icon-fuzhi,
.icon-xiazai {
margin-right: 5px;
font-size: 14px;
}
}
// 渲染错误样式
:deep(.render-error) {
color: $color-error;
font-weight: 500;
margin: $spacing-lg 0;
}
// Toast提示样式
.toast {
position: fixed;
top: $spacing-md;
right: $spacing-md;
padding: spacing-sm spacing-md;
border-radius: $radius-md;
box-shadow: $shadow-lg;
z-index: 9999;
@include transition(opacity, $transition-normal);
opacity: 1;
}
.toast-success {
background-color: $color-success;
color: #ffffff;
}
.toast-error {
background-color: $color-error;
color: #ffffff;
}
.toast-warning {
background-color: $color-warning;
color: #ffffff;
}
</style>
pdfUtils.ts文件代码:
// Promise.withResolvers polyfill --- pdfjs-dist 4.x 依赖此 ES2024 API
if (typeof (Promise as any).withResolvers !== 'function') {
(Promise as any).withResolvers = function () {
let resolve: any, reject: any;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
}
// 使用modern构建但禁用可能导致私有字段访问的功能
import * as pdfjsLib from 'pdfjs-dist/build/pdf.mjs';
import type { PdfElement, PdfLayoutInfo } from '../types';
// 配置PDF.js worker - 使用public目录中的本地版本避免CORS问题
pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdfjs-dist/build/pdf.worker.wrapper.mjs';
// 使用正确的API
const { getDocument } = pdfjsLib;
// 加载PDF文档
export async function loadPdfDocument(file: File): Promise<any> {
try {
const data = await file.arrayBuffer();
const pdfData = new Uint8Array(data);
const loadingTask = getDocument({
data: pdfData,
useSystemFonts: true,
cMapUrl: '/pdfjs-dist/cmaps/',
cMapPacked: true,
standardFontDataUrl: '/pdfjs-dist/standard_fonts/'
});
if (!loadingTask || typeof loadingTask.promise !== 'object') {
throw new Error('Failed to create PDF loading task');
}
const pdfDoc = await loadingTask.promise;
if (!pdfDoc || typeof pdfDoc.numPages !== 'number' || typeof pdfDoc.getPage !== 'function') {
throw new Error('Invalid PDF document loaded');
}
return pdfDoc;
} catch (error) {
console.error('加载PDF文档失败:', error);
throw new Error(`PDF加载失败: ${error instanceof Error ? error.message : '未知错误'}`);
}
}
/**
* 从API加载PDF文档
* @param pdfUint8 PDF文件的Uint8Array数据
*/
export async function loadPdfDocumentApi(pdfUint8: Uint8Array): Promise<any> {
try {
// 检查 PDF 数据是否存在
if (!pdfUint8) {
throw new Error('PDF数据为空,无法加载文档');
}
// 创建Uint8Array的副本,避免PDF.js修改原始数据
const pdfDataCopy = new Uint8Array(pdfUint8);
const loadingTask = getDocument({
data: pdfDataCopy,
useSystemFonts: true,
cMapUrl: '/pdfjs-dist/cmaps/',
cMapPacked: true,
standardFontDataUrl: '/pdfjs-dist/standard_fonts/'
});
if (!loadingTask || typeof loadingTask.promise !== 'object') {
throw new Error('Failed to create PDF loading task');
}
const pdfDoc = await loadingTask.promise;
if (!pdfDoc || typeof pdfDoc.numPages !== 'number' || typeof pdfDoc.getPage !== 'function') {
throw new Error('Invalid PDF document loaded');
}
return pdfDoc;
} catch (error) {
console.error('加载PDF文档失败:', error);
throw new Error(`PDF加载失败: ${error instanceof Error ? error.message : '未知错误'}`);
}
}
/**
* 渲染PDF页面
*/
export async function renderPdfPage(
pdfDoc: any,
pageNum: number,
_container: HTMLElement,
zoom: number = 1.0
): Promise<{ canvas: HTMLCanvasElement; displayViewport: any }> {
try {
if (!pdfDoc || typeof pdfDoc.getPage !== 'function') {
throw new Error('Invalid PDF document object');
}
const page: any = await pdfDoc.getPage(pageNum);
// 创建画布
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) {
throw new Error('无法创建Canvas上下文');
}
// 获取设备像素比,提高高分辨率屏幕上的渲染清晰度
const devicePixelRatio = window.devicePixelRatio || 1;
// 获取视口
const displayViewport = page.getViewport({ scale: zoom });
// 计算渲染缩放比例
const renderScale = zoom * devicePixelRatio;
// 获取高分辨率视口
const viewport = page.getViewport({ scale: renderScale });
// 设置画布的实际像素尺寸
canvas.width = Math.floor(viewport.width);
canvas.height = Math.floor(viewport.height);
canvas.style.display = 'block';
// 统一显示尺寸,避免页容器和 canvas 因小数取整不一致而露出纸面边缘
const displayWidth = Math.round(displayViewport.width);
const displayHeight = Math.round(displayViewport.height);
canvas.style.display = 'block';
canvas.style.width = `${displayWidth}px`;
canvas.style.height = `${displayHeight}px`;
// 渲染页面
const renderContext = {
canvasContext: context,
viewport: viewport
};
const renderTask = page.render(renderContext);
if (!renderTask || typeof renderTask.promise !== 'object') {
throw new Error('Failed to create render task');
}
await renderTask.promise;
return { canvas, displayViewport };
} catch (error) {
console.error(`渲染页面 ${pageNum} 失败:`, error);
throw new Error(`页面渲染失败: ${error instanceof Error ? error.message : '未知错误'}`);
}
}
/**
* 从PDF文档中提取文本内容
*/
export async function extractPdfText(pdfDoc: any): Promise<string> {
try {
if (!pdfDoc || typeof pdfDoc.numPages !== 'number') {
throw new Error('Invalid PDF document');
}
let fullText = '';
for (let pageNum = 1; pageNum <= pdfDoc.numPages; pageNum++) {
const page = await pdfDoc.getPage(pageNum);
if (!page || typeof page.getTextContent !== 'function') {
throw new Error(`Invalid PDF page ${pageNum}`);
}
const content = await page.getTextContent();
const pageText = content.items
.map((item: any) => item.str)
.join(' ');
fullText += pageText + '\n\n';
// 添加页码标记到页面尾部
fullText += `<!-- page ${pageNum} -->\n\n`;
}
return fullText.trim();
} catch (error) {
console.error('提取PDF文本失败:', error);
throw new Error(`文本提取失败: ${error instanceof Error ? error.message : '未知错误'}`);
}
}
/**
* 从PDF文档中提取元素信息
*/
export async function extractPdfElements(pdfDoc: any): Promise<PdfElement\[\]> {
try {
// Validate pdfDoc
if (!pdfDoc || typeof pdfDoc.numPages !== 'number') {
throw new Error('Invalid PDF document');
}
const elements: PdfElement\[\] = \[\];
let globalIndex = 0;
for (let pageNum = 1; pageNum <= pdfDoc.numPages; pageNum++) {
const page = await pdfDoc.getPage(pageNum);
if (!page || typeof page.getTextContent !== 'function' || typeof page.getViewport !== 'function') {
throw new Error(`Invalid PDF page ${pageNum}`);
}
const content = await page.getTextContent();
const viewport = page.getViewport({ scale: 1.0 });
let elementId = 0;
for (const item of content.items) {
const element: PdfElement = {
id: `pdf-element-{pageNum}-{elementId++}`,
type: 'text',
page: pageNum,
content: item.str,
bbox: calculateBbox(item, viewport),
globalIndex: globalIndex++
};
elements.push(element);
}
}
return elements;
} catch (error) {
console.error('提取PDF元素失败:', error);
throw new Error(`元素提取失败: ${error instanceof Error ? error.message : '未知错误'}`);
}
}
/**
* 计算元素的边界框
*/
function calculateBbox(item: any, viewport: any): number, number, number, number {
// 确保item.transform存在
if (!item.transform || !Array.isArray(item.transform)) {
return 0, 0, 100, 20; // 默认边界框
}
// 计算边界框
const x = item.transform4;
const y = viewport.height - item.transform5;
const width = item.width || 100;
const height = item.height || 20;
return x, y, x + width, y + height;
}
/**
* 解析JSON布局信息
*/
export function parseJsonLayoutInfo(jsonContent: any): PdfLayoutInfo\[\] {
try {
if (Array.isArray(jsonContent) && jsonContent.length > 0 && Array.isArray(jsonContent0)) {
const flattenedPages = jsonContent;
const layoutInfo: PdfLayoutInfo\[\] = flattenedPages.map((pageData: any\[\], pageIndex: number) => {
const pageNum = pageIndex + 1;
const pageSize: number, number = 595, 842;
if (pageData.length > 0 && pageData0.bbox) {
const width = pageData0.bbox2 - pageData0.bbox0;
const height = pageData0.bbox3 - pageData0.bbox1;
if (width > 0 && height > 0) {
pageSize0 = width;
pageSize1 = height;
}
}
return {
page: pageNum,
page_size: pageSize,
para_blocks: pageData
};
});
return layoutInfo;
} else if (Array.isArray(jsonContent)) {
const layoutInfo: PdfLayoutInfo\[\] = jsonContent.map((pageData: any, index: number) => {
const pageNum = index + 1;
const pageSize: number, number = pageData.page_size || 595, 842; // 默认A4尺寸
const paraBlocks = pageData.para_blocks || \[\];
return {
page: pageNum,
page_size: pageSize,
para_blocks: paraBlocks
};
});
return layoutInfo;
} else {
throw new Error('JSON格式错误:根节点应为数组');
}
} catch (error) {
console.error('解析JSON布局信息失败:', error);
throw new Error(`JSON解析失败: ${error instanceof Error ? error.message : '未知错误'}`);
}
}
/**
* 从布局信息生成PDF元素
*/
export function generatePdfElementsFromLayout(layoutInfo: PdfLayoutInfo\[\]): PdfElement\[\] {
try {
const elements: PdfElement\[\] = \[\];
let globalIndex = 0; // 全局元素索引,用于严格映射
// 按顺序遍历每个页面布局
layoutInfo.forEach((pageLayout, _pageIndex) => {
const pageNum = pageLayout.page;
let pageElementIndex = 0; // 页面内元素索引
// 严格按照JSON中para_blocks的顺序遍历,不改变顺序
pageLayout.para_blocks.forEach((block, _blockIndex) => {
// 过滤掉空内容元素
if (!block.content || block.content.trim() === '') {
return;
}
// 使用原始的PDF bbox坐标,保持x_min, y_min, x_max, y_max格式
const bbox = block.bbox || 0, 0, 100, 20;
const element: PdfElement = {
id: `pdf-element-{pageNum}-{pageElementIndex}`,
type: block.type || 'text',
page: pageNum,
content: block.content || '',
bbox: bbox, // 使用原始bbox格式 x_min, y_min, x_max, y_max
tableHtml: block.html,
tableText: block.content,
isTableSubBlock: block.type === 'table_sub_block',
isTableLine: block.type === 'table_line',
isListLine: block.type === 'list_line',
originalBlock: block,
elementIndex: pageElementIndex, // 页面内元素索引
globalIndex: globalIndex // 全局元素索引,用于严格映射
};
elements.push(element);
pageElementIndex++;
globalIndex++;
});
});
return elements;
} catch (error) {
console.error('从布局信息生成PDF元素失败:', error);
throw new Error(`元素生成失败: ${error instanceof Error ? error.message : '未知错误'}`);
}
}
/**
* 格式化文件大小
*/
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = 'Bytes', 'KB', 'MB', 'GB';
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizesi;
}
/**
* 生成Markdown内容
*/
export function generateMarkdownContent(pdfElements: PdfElement\[\]): string {
try {
let markdown = '';
// 按页面分组并保持页面顺序
const pageNumbers = new Set<number>();
const pages = new Map<number, PdfElement\[\]>();
let pageOrder: number\[\] = \[\];
// 首次遍历确定页面顺序
pdfElements.forEach(element => {
if (!pageNumbers.has(element.page)) {
pageNumbers.add(element.page);
pageOrder.push(element.page);
}
const pageElements = pages.get(element.page) || \[\];
pageElements.push(element);
pages.set(element.page, pageElements);
});
// 生成Markdown
pageOrder.forEach(pageNum => {
const elements = pages.get(pageNum) || \[\];
// 添加页面开始标记
markdown += `<!-- 开始第 ${pageNum} 页 -->\n\n`;
// 处理元素
elements.forEach((element, elementIndex) => {
// 内容已在generatePdfElementsFromLayout中过滤过,这里确保安全
const content = element.content ? element.content.trim() : '';
// 为元素添加页面、元素索引和全局索引信息
const elementIndexComment = `<!-- page:{pageNum} element:{elementIndex} id:{element.id} globalIndex:{element.globalIndex} -->\n`;
// 处理所有元素类型,包括表格子块和列表行
switch (element.type) {
case 'header':
markdown += `{elementIndexComment}# {content}\n\n`;
break;
case 'title':
markdown += `{elementIndexComment}## {content}\n\n`;
break;
case 'subtitle':
markdown += `{elementIndexComment}### {content}\n\n`;
break;
case 'heading':
markdown += `{elementIndexComment}#### {content}\n\n`;
break;
case 'table':
// 如果有HTML表格,优先使用HTML表格
if (element.tableHtml) {
markdown += `{elementIndexComment}{element.tableHtml}\n\n`;
} else if (element.tableText) {
markdown += `{elementIndexComment}{element.tableText}\n\n`;
} else {
markdown += `{elementIndexComment}{content}\n\n`;
}
break;
case 'table_caption':
markdown += `{elementIndexComment}_{content}_\n\n`;
break;
case 'table_sub_block':
// 表格子块内容添加注释标记,确保表格行之间有正确分隔
markdown += `{elementIndexComment}{content}\n\n`;
break;
case 'table_line':
// 表格行内容添加注释标记,确保表格行之间有正确分隔
markdown += `{elementIndexComment}{content}\n\n`;
break;
case 'code':
markdown += `${elementIndexComment}\`\`\`
${content}
\`\`\`\n\n`;
break;
case 'quote':
markdown += `{elementIndexComment}\> {content}\n\n`;
break;
case 'list':
case 'list_line':
markdown += `{elementIndexComment}- {content}\n\n`;
break;
case 'ordered_list':
// 处理有序列表
markdown += `{elementIndexComment}1. {content}\n\n`;
break;
case 'page_number':
// 确保页码内容显示在markdown中
markdown += `{elementIndexComment}{content}\n\n`;
break;
default:
// 对于普通文本和其他类型,确保内容被正确添加
markdown += `{elementIndexComment}{content}\n\n`;
}
});
// 确保列表结束后添加空行
if (markdown.endsWith('\n')) {
markdown += '\n';
}
// 添加页码标记到页面尾部
markdown += `<!-- page ${pageNum} -->\n\n`;
});
// 移除开头和结尾的多余空行,但确保在内容末尾保留一个空行以符合Markdown格式规范
const trimmedMarkdown = markdown.trim();
return trimmedMarkdown + '\n\n';
} catch (error) {
console.error('生成Markdown内容失败:', error);
throw new Error(`Markdown生成失败: ${error instanceof Error ? error.message : '未知错误'}`);
}
}
/**
* 计算两个字符串的相似度
* 使用简单的字符匹配算法
*/
function calculateSimilarity(str1: string, str2: string): number {
if (!str1 || !str2) return 0;
const lowerStr1 = str1.toLowerCase().trim();
const lowerStr2 = str2.toLowerCase().trim();
if (lowerStr1 === lowerStr2) return 1.0;
const maxLength = Math.max(lowerStr1.length, lowerStr2.length);
if (maxLength === 0) return 1.0;
let matches = 0;
for (let i = 0; i < Math.min(lowerStr1.length, lowerStr2.length); i++) {
if (lowerStr1i === lowerStr2i) {
matches++;
}
}
// 基础相似度 + 长度相似性调整
const baseSimilarity = matches / maxLength;
const lengthRatio = Math.min(lowerStr1.length, lowerStr2.length) / maxLength;
return baseSimilarity * 0.7 + lengthRatio * 0.3;
}
/**
* 创建PDF元素与Markdown元素之间的映射关系
* 优先使用全局索引/页面内索引进行一一映射,
* 仅在索引缺失时回退到内容相似度匹配
*/
export function createElementMappings(pdfElements: PdfElement\[\], mdElements: any\[\]): any\[\] {
try {
const mappings: any\[\] = \[\];
// 重置Markdown元素状态
mdElements.forEach(mdElement => {
mdElement.used = false;
mdElement.mappedPdfId = null;
mdElement.mappedPdfType = null;
});
// 1. 基于globalIndex进行严格的一一映射
const mdByGlobalIndex: Map<number, any> = new Map();
mdElements.forEach(mdElement => {
if (mdElement.globalIndex !== undefined && mdElement.globalIndex !== null) {
mdByGlobalIndex.set(mdElement.globalIndex, mdElement);
}
});
pdfElements.forEach((pdfElement, pdfIndex) => {
const mdElement = mdByGlobalIndex.get(pdfElement.globalIndex);
if (mdElement && !mdElement.used) {
mdElement.used = true;
mdElement.mappedPdfId = pdfElement.id;
mdElement.mappedPdfType = pdfElement.type;
pdfElement.mdIndex = mdElements.indexOf(mdElement);
mappings.push({
pdfId: pdfElement.id,
mdId: mdElement.id,
type: pdfElement.type.includes('table') ? 'table' : 'normal',
pdfPage: pdfElement.page,
pdfIndex: pdfIndex,
mdIndex: mdElements.indexOf(mdElement),
fullIndex: pdfElement.globalIndex
});
}
});
// 2. 对于仍未映射的元素,尝试使用(page, elementIndex)进行映射
const unmappedPdfElements = pdfElements.filter(pdfEl => !mappings.find(m => m.pdfId === pdfEl.id));
const unmappedMdElements = mdElements.filter(mdEl => !mdEl.used);
if (unmappedPdfElements.length > 0 && unmappedMdElements.length > 0) {
const mdByPageElementIndex: Map<string, any> = new Map();
unmappedMdElements.forEach(mdElement => {
if (
mdElement.page !== undefined &&
mdElement.page !== null &&
mdElement.elementIndex !== undefined &&
mdElement.elementIndex !== null
) {
const key = `{mdElement.page}-{mdElement.elementIndex}`;
mdByPageElementIndex.set(key, mdElement);
}
});
unmappedPdfElements.forEach((pdfElement, pdfIndex) => {
if (
pdfElement.page !== undefined &&
pdfElement.page !== null &&
pdfElement.elementIndex !== undefined &&
pdfElement.elementIndex !== null
) {
const key = `{pdfElement.page}-{pdfElement.elementIndex}`;
const mdElement = mdByPageElementIndex.get(key);
if (mdElement && !mdElement.used) {
mdElement.used = true;
mdElement.mappedPdfId = pdfElement.id;
mdElement.mappedPdfType = pdfElement.type;
pdfElement.mdIndex = mdElements.indexOf(mdElement);
mappings.push({
pdfId: pdfElement.id,
mdId: mdElement.id,
type: pdfElement.type.includes('table') ? 'table' : 'normal',
pdfPage: pdfElement.page,
pdfIndex: pdfIndex,
mdIndex: mdElements.indexOf(mdElement),
fullIndex: pdfElement.globalIndex
});
}
}
});
}
// 3. 对于索引信息缺失的少量残留元素,回退到相似度匹配以尽量建立映射
const stillUnmappedPdfElements = pdfElements.filter(pdfEl => !mappings.find(m => m.pdfId === pdfEl.id));
const stillUnmappedMdElements = mdElements.filter(mdEl => !mdEl.used);
if (stillUnmappedPdfElements.length > 0 && stillUnmappedMdElements.length > 0) {
stillUnmappedPdfElements.forEach((pdfElement, pdfIndex) => {
const pdfContent = pdfElement.content ? pdfElement.content.toLowerCase().trim() : '';
let bestMatch: any = null;
let bestScore = 0;
stillUnmappedMdElements.forEach(mdElement => {
if (mdElement.used) return;
const mdContent = mdElement.content ? mdElement.content.toLowerCase().trim() : '';
const similarity = calculateSimilarity(pdfContent, mdContent);
if (similarity > bestScore && similarity > 0.7) {
bestScore = similarity;
bestMatch = mdElement;
}
});
if (bestMatch) {
bestMatch.used = true;
bestMatch.mappedPdfId = pdfElement.id;
bestMatch.mappedPdfType = pdfElement.type;
pdfElement.mdIndex = mdElements.indexOf(bestMatch);
mappings.push({
pdfId: pdfElement.id,
mdId: bestMatch.id,
type: pdfElement.type.includes('table') ? 'table' : 'normal',
pdfPage: pdfElement.page,
pdfIndex: pdfIndex,
mdIndex: mdElements.indexOf(bestMatch),
fullIndex: pdfElement.globalIndex
});
}
});
}
return mappings;
} catch (error) {
console.error('创建元素映射失败:', error);
return \[\];
}
}
useZipJsonAndPdf.ts文件代码:
import JSZip from 'jszip';
import axios from 'axios';
import { ElMessage } from 'element-plus';
function getPreviewUrl(file_id: string) {
const envUrl = import.meta.env.VITE_PREVIEW_URL;
const defaultUrl = `/api/preview/`;
return envUrl && envUrl.length > 0 ? envUrl + file_id : defaultUrl + file_id;
}
// 从arraybuffer响应中提取后端返回的提示信息
function extractBackendMessage(data: any): string | null {
if (!data) return null;
// 已经是字符串或对象(非arraybuffer场景)
if (typeof data === 'string') {
try {
const parsed = JSON.parse(data);
return parsed?.message || parsed?.msg || parsed?.detail || parsed?.error || data;
} catch {
return data;
}
}
if (data instanceof ArrayBuffer) {
try {
const text = new TextDecoder('utf-8').decode(data);
const parsed = JSON.parse(text);
return parsed?.message || parsed?.msg || parsed?.detail || parsed?.error || text;
} catch {
return null;
}
}
if (typeof data === 'object') {
return data?.message || data?.msg || data?.detail || data?.error || null;
}
return null;
}
export function useZipJsonAndPdf() {
const loading = ref(false);
const error = ref<string | null>(null);
const jsonData = ref<any | null>(null); // 重命名为jsonData,避免与error变量冲突
const jsonRaw = ref<string | null>(null);
const pdfUint8 = ref<Uint8Array | null>(null);
const pdfName = ref<string>('');
async function loadFromUrl(file_id: string) {
loading.value = true;
error.value = null;
jsonData.value = null;
jsonRaw.value = null;
pdfUint8.value = null;
pdfName.value = '';
try {
const res = await axios.get(getPreviewUrl(file_id), { responseType: 'arraybuffer' });
const zip = await JSZip.loadAsync(res.data);
const entries = Object.values(zip.files);
let jsonEntry = null;
let pdfEntry = null;
for (const entry of entries) {
if (entry.dir) continue;
const lower = entry.name.toLowerCase();
if (lower.endsWith('.json')) jsonEntry = entry;
else if (lower.endsWith('.pdf')) {
pdfEntry = entry;
pdfName.value = entry.name;
}
}
if (!jsonEntry) {
throw new Error('no json file found in zip');
}
if (!pdfEntry) {
throw new Error('no pdf file found in zip');
}
const jsonText = await jsonEntry.async('text');
jsonRaw.value = jsonText;
try {
jsonData.value = JSON.parse(jsonText);
} catch {
jsonData.value = null;
}
const arrayBuffer = await pdfEntry.async('arraybuffer');
pdfUint8.value = new Uint8Array(arrayBuffer);
} catch (e: any) {
// 优先使用后端返回的提示信息,其次使用axios错误信息,最后兜底默认文案
const backendMsg = extractBackendMessage(e?.response?.data);
const msg = backendMsg || e?.message || '预览接口请求失败';
error.value = msg;
ElMessage.error(msg);
} finally {
loading.value = false;
}
}
return {
loading,
error,
jsonData,
jsonRaw,
pdfUint8,
pdfName,
loadFromUrl,
};
}
globalFunctions.ts文件代码:
// 扩展Window接口,添加全局函数声明
declare global {
interface Window {
handlePdfElementHover?: (element: HTMLElement) => void;
removeTypeLabel?: (element: HTMLElement) => void;
}
}
const CSMAR_MODEL_TYPE: Record<string, string> = {
"text": "文本",
"title": "标题",
"equation": "行间公式",
"image": "图片",
"image_caption": "图片描述",
"image_footnote": "图片脚注",
"table": "表格",
"table_caption": "表格描述",
"table_footnote": "表格脚注",
"phonetic": "拼音",
"code": "代码块",
"code_caption": "代码描述",
"ref_text": "参考文献",
"algorithm": "算法块",
"list": "列表",
"header": "页眉",
"footer": "页脚",
"page_number": "页码",
"aside_text": "装订线旁注",
"page_footnote": "页面脚注",
"catalog list": "目录",
"figure": "图片",
"figure caption": "图片描述",
"reference": "引用",
"table caption": "表格标题",
};
// 全局函数:处理PDF元素悬停显示类型信息和高亮
export function handlePdfElementHover(element: HTMLElement): void {
if (!element || !element.dataset.elementType) return;
// 显示类型标签
showTypeLabel(element);
const elementType = element.dataset.elementType || 'unknown';
// 表格不显示复制按钮
if (elementType === 'table') {
// 设置高亮样式但不添加复制按钮
element.style.backgroundColor = 'rgba(16, 185, 129, 0.3) !important';
element.style.boxShadow = '0 0 0 2px rgba(16, 185, 129, 0.7) !important';
element.style.zIndex = '1000 !important';
element.style.pointerEvents = 'auto !important';
element.style.cursor = 'pointer !important';
element.style.position = 'absolute !important';
element.style.transition = 'all 0.2s ease !important';
element.style.borderRadius = '4px !important';
element.classList.add('pdf-element-hovered');
return; // 直接返回,不创建复制按钮
}
// 添加复制按钮到右下角
const copyBtn = document.createElement('div');
copyBtn.className = 'copy-btn';
copyBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>`;
copyBtn.title = '复制文本';
copyBtn.style.cssText = `
position: absolute !important;
top: 100% !important;
right: 0 !important;
margin-top: 4px !important;
background-color: #10b981 !important;
color: white !important;
width: 26px !important;
height: 26px !important;
border-radius: 4px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
cursor: pointer !important;
z-index: 9999 !important;
pointer-events: auto !important;
opacity: 0.9 !important;
transition: all 0.2s ease !important;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2) !important;
`;
// 复制按钮悬停效果
copyBtn.addEventListener('mouseenter', () => {
copyBtn.style.opacity = '1';
});
copyBtn.addEventListener('mouseleave', () => {
copyBtn.style.opacity = '0.9';
});
// 复制按钮点击事件
copyBtn.addEventListener('click', async (e) => {
e.stopPropagation();
const content = element.dataset.elementContent || '';
if (content) {
try {
// 尝试使用现代剪贴板API
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(content);
ElMessage.success('复制成功');
} else {
// 降级方案:使用传统的document.execCommand('copy')
const textArea = document.createElement('textarea');
textArea.value = content;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand('copy');
if (successful) {
ElMessage.success('复制成功');
} else {
throw new Error('execCommand failed');
}
} catch (err) {
// 再次降级:提示用户手动复制
ElMessage.info('请手动复制文本');
} finally {
document.body.removeChild(textArea);
}
}
} catch (err) {
console.error('复制失败:', err);
ElMessage.error('复制失败,请手动复制');
}
}
});
element.appendChild(copyBtn);
// 强制添加高亮类
element.classList.add('pdf-element-hovered');
}
// 全局函数:显示类型标签
export function showTypeLabel(element: HTMLElement): void {
if (!element || !element.dataset.elementType) return;
removeTypeLabel(element);
const elementType = element.dataset.elementType || 'unknown';
const labelText = CSMAR_MODEL_TYPEelementType ?? elementType;
const typeLabel = document.createElement('div');
typeLabel.className = 'type-label';
typeLabel.textContent = labelText;
typeLabel.style.cssText = `
position: absolute !important;
top: -28px !important;
left: 0 !important;
padding: 4px 10px !important;
border-radius: 4px 4px 4px 0 !important;
font-size: 12px !important;
font-weight: 600 !important;
z-index: 9999 !important;
pointer-events: none !important;
opacity: 1 !important;
transition: all 0.2s ease !important;
white-space: nowrap !important;
letter-spacing: 0.5px !important;
`;
element.appendChild(typeLabel);
}
// 全局函数:移除类型标签和高亮效果
export function removeTypeLabel(element: HTMLElement): void {
if (!element) return;
// 移除类型标签
element.querySelectorAll<HTMLElement>('.type-label').forEach(label => {
label.style.opacity = '0';
setTimeout(() => {
if (label.parentNode) {
label.parentNode.removeChild(label);
}
}, 200);
});
// 移除复制按钮
element.querySelectorAll<HTMLElement>('.copy-btn').forEach(btn => {
btn.style.opacity = '0';
setTimeout(() => {
if (btn.parentNode) {
btn.parentNode.removeChild(btn);
}
}, 200);
});
// 移除背景高亮和边框效果
if (!element.classList.contains('highlighted')) {
// 移除高亮类
element.classList.remove('pdf-element-hovered');
// 重置样式
element.style.backgroundColor = '';
element.style.border = '';
element.style.boxShadow = '';
element.style.zIndex = '1';
element.style.transition = 'all 0.2s ease';
element.style.borderRadius = '';
}
}
// 初始化全局函数
export function initializeGlobalFunctions(): void {
window.handlePdfElementHover = handlePdfElementHover;
window.removeTypeLabel = removeTypeLabel;
}
// 清理全局函数
export function cleanupGlobalFunctions(): void {
delete window.handlePdfElementHover;
delete window.removeTypeLabel;
}
type类型定义文件代码:
// PDF相关类型定义
export interface PdfElement {
id: string;
type: string;
page: number;
content: string;
bbox: number, number, number, number;
tableHtml?: string;
tableText?: string;
isTableSubBlock?: boolean;
isTableLine?: boolean;
isListLine?: boolean;
originalBlock?: any;
elementIndex?: number;
globalIndex: number; // 全局元素索引,用于确保严格的映射顺序
mdIndex?: number;
}
// Markdown相关类型定义
export interface MarkdownElement {
id: string;
type: string;
content: string;
element: HTMLElement;
used?: boolean;
mappedPdfId?: string;
mappedPdfType?: string;
estimatedPage?: number;
page?: number; // 页面索引
elementIndex?: number; // 页面内元素索引
fullIndex?: number; // 全文索引(用于表格类型元素)
globalIndex?: number; // 全局元素索引,用于确保严格的映射顺序
pdfIndex?: number; // PDF元素索引
}
// 映射关系类型定义
export interface ElementMapping {
pdfId: string;
mdId: string;
type: 'table' | 'normal';
pdfPage: number;
pdfIndex?: number; // PDF元素索引
mdIndex?: number; // Markdown元素索引
fullIndex?: number; // 全文索引(用于表格类型元素)
}
第三方插件自行下载:
"marked": "^17.0.1","pdfjs-dist": "^4.0.189","axios": "^1.5.0","highlight.js": "^11.11.1",