Nymph_Zhu头像
pdf转换markdown预览及内容互相点击定位封面图

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 = pageMdElements[0].id;

  const endId = pageMdElements[pageMdElements.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;

    pageStats[page] = (pageStats[page] || 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 = pageStats[page];

    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 = sortedPages[sortedPages.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 = bbox[0] * zoom;

    const top = bbox[1] * zoom;

    const width = (bbox[2] - bbox[0]) * zoom;

    const height = (bbox[3] - bbox[1]) * 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(match[1], 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 div[class*="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(input[type="number"]) {

  -moz-appearance: textfield;

  text-align: center;

}

:deep(input[type="number"]::-webkit-outer-spin-button),

:deep(input[type="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(globalIndexMatch[1], 10);

        const index = parseInt(globalIndexMatch[2], 10);

        const pdfId = globalIndexMatch[3];

        const globalIndex = parseInt(globalIndexMatch[4], 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(pageIndexMatch[1], 10);

          const index = parseInt(pageIndexMatch[2], 10);

          const pdfId = pageIndexMatch[3];

          const fullIndex = pageIndexMatch[4] ? parseInt(pageIndexMatch[4], 10) : 0;

          const globalIndex = pageIndexMatch[5] ? parseInt(pageIndexMatch[5], 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.transform[4];

  const y = viewport.height - item.transform[5];

  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(jsonContent[0])) {

      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 && pageData[0].bbox) {

          const width = pageData[0].bbox[2] - pageData[0].bbox[0];

          const height = pageData[0].bbox[3] - pageData[0].bbox[1];

          if (width > 0 && height > 0) {

            pageSize[0] = width;

            pageSize[1] = 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)) + ' ' + sizes[i];

}

/**

 * 生成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 (lowerStr1[i] === lowerStr2[i]) {

      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_TYPE[elementType] ?? 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",

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/Mirs_Zhu/article/details/169559031

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--