pdf转换markdown预览及内容互相点击定位

预览主页面组件代码:

<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",

相关推荐
计算机魔术师2 小时前
Google 一天蒸发5000亿,Reddit 靠 AI 训练喂得饱自己却饿死
前端
小大宇2 小时前
Vue知识点-ElementPlus-05 输入框
前端·javascript·vue.js
king王一帅3 小时前
AI 时代少有人类阅读,我开始建自己的独立站,但真的是 49 年入国军吗
开源·ai编程·markdown
l1m0_3 小时前
AI设计工具有哪些?Paico、Pixso AI、墨刀AI、Lovable、Codex怎么选
前端·人工智能·ai·设计·墨刀·codex·pixso
web打印社区3 小时前
Lodop在Edge和双核浏览器-先分清内核
前端·javascript·edge·pdf·ecmascript·html5
西瓜拿铁好喝3 小时前
开源和闭源 AI 办公工具,分别适合谁
java·前端·数据库
IT_陈寒3 小时前
深入理解SpringBoot:核心原理与最佳实践
前端·人工智能·后端
行者-全栈开发3 小时前
【鸿蒙心迹】鸿蒙 PC 应用上架实战:分布式 Markdown 笔记从开发到过审全流程
harmonyos·markdown·arkui·应用上架·鸿蒙 pc·分布式数据同步
是店小二呀3 小时前
敏感PDF不想上传陌生网站?我把Stirling-PDF装进NAS自己处理
pdf