在 PDF.js 的 viewer.html
中实现同一 PDF 文件在网页中上下拆分显示,并且上下两部分添加的标注数据能够实时同步,这是一个比较高级的定制化需求。PDF.js 本身不直接提供开箱即用的"同一文档双视图同步标注"功能,因此需要进行较多的开发和改造。
这里提供一个实现思路和关键步骤:
核心思路:
最直接且可能相对容易管理的方式是创建两个独立的 PDF.js 查看器实例,让它们加载同一个 PDF 文件。然后,关键在于建立一个机制来监听一个实例中的标注变化,并将这些变化实时应用到另一个实例上。
实现步骤:
-
修改
viewer.html
结构:- 将原有的主内容区域(通常是
#viewerContainer
或类似的容器)拆分成两个独立的容器,例如#viewerContainerTop
和#viewerContainerBottom
。 - 使用 CSS(例如 Flexbox 或 Grid 布局)来设置这两个容器,使它们在页面上垂直排列,各占一部分高度(比如各 50%),并确保它们都有独立的滚动条 (
overflow: auto;
)。
html<!-- 简化示例 --> <body tabindex="1"> <div id="outerContainer"> <!-- ... 其他工具栏等 ... --> <div id="mainContainer"> <div id="viewerContainerTop" class="split-viewer"> <div id="viewerTop" class="pdfViewer"></div> </div> <div id="viewerContainerBottom" class="split-viewer"> <div id="viewerBottom" class="pdfViewer"></div> </div> </div> <!-- ... 其他侧边栏等 ... --> </div> <!-- ... 其他元素和脚本 ... --> </body>
css/* 简化示例 CSS */ #mainContainer { display: flex; flex-direction: column; height: calc(100vh - /* header height */); /* 减去头部工具栏高度 */ } .split-viewer { flex: 1; /* 均分高度 */ overflow: auto; /* 独立滚动 */ position: relative; /* 用于内部绝对定位元素 */ } /* 可能需要调整 .pdfViewer 的样式 */
- 将原有的主内容区域(通常是
-
初始化两个 PDF.js 实例:
- 在
viewer.js
(或相关的应用初始化脚本) 中,你需要修改PDFViewerApplication
的初始化逻辑,或者创建两个独立的PDFViewerApplication
实例(这可能更复杂,因为PDFViewerApplication
是一个单例设计)。 - 更可行的方法可能是:保持一个
PDFViewerApplication
来管理核心逻辑(如文档加载、权限等),但创建两个独立的PDFViewer
实例,并将它们分别关联到#viewerTop
和#viewerBottom
这两个 DOM 元素上。 - 你需要确保两个
PDFViewer
实例都使用同一个加载后的PDFDocumentProxy
对象。
javascript// 在 viewer.js 或类似文件中修改或扩展 // 这部分代码需要深入理解 PDFViewerApplication 的结构,可能需要重构 // 假设 PDFViewerApplication.pdfDocument 已经加载完成 const pdfDocument = PDFViewerApplication.pdfDocument; const eventBusTop = new pdfjsViewer.EventBus(); const eventBusBottom = new pdfjsViewer.EventBus(); // 创建链接服务,可能需要为每个视图创建独立的 const pdfLinkServiceTop = new pdfjsViewer.PDFLinkService({ eventBus: eventBusTop }); const pdfLinkServiceBottom = new pdfjsViewer.PDFLinkService({ eventBus: eventBusBottom }); // 创建查找控制器,可能需要独立的 const pdfFindControllerTop = new pdfjsViewer.PDFFindController({ /* ... */ }); const pdfFindControllerBottom = new pdfjsViewer.PDFFindController({ /* ... */ }); // *** 关键:创建两个 PDFViewer 实例 *** const pdfViewerTop = new pdfjsViewer.PDFViewer({ container: document.getElementById('viewerContainerTop'), viewer: document.getElementById('viewerTop'), eventBus: eventBusTop, linkService: pdfLinkServiceTop, findController: pdfFindControllerTop, // ... 其他必要的配置,如 textLayerMode, annotationMode 等 textLayerMode: pdfjsViewer.TextLayerMode.ENABLE, annotationMode: pdfjsViewer.AnnotationMode.ENABLE_STORAGE, // 启用存储 }); pdfLinkServiceTop.setViewer(pdfViewerTop); const pdfViewerBottom = new pdfjsViewer.PDFViewer({ container: document.getElementById('viewerContainerBottom'), viewer: document.getElementById('viewerBottom'), eventBus: eventBusBottom, linkService: pdfLinkServiceBottom, findController: pdfFindControllerBottom, // ... 其他必要的配置 textLayerMode: pdfjsViewer.TextLayerMode.ENABLE, annotationMode: pdfjsViewer.AnnotationMode.ENABLE_STORAGE, // 启用存储 }); pdfLinkServiceBottom.setViewer(pdfViewerBottom); // *** 将同一个文档设置给两个 Viewer *** pdfViewerTop.setDocument(pdfDocument); pdfViewerBottom.setDocument(pdfDocument); // 将 Viewer 设置给 LinkService (如果需要的话) // pdfLinkServiceTop.setDocument(pdfDocument, null); // pdfLinkServiceBottom.setDocument(pdfDocument, null); // 你可能需要将这两个 viewer 实例集成到 PDFViewerApplication 的管理中 // 例如,更新缩放、页面跳转等需要同时作用于两个视图,或者提供独立的控制 // 这部分需要根据 PDFViewerApplication 的具体实现进行调整
- 在
-
实现标注同步:
- 这是最核心和复杂的部分。你需要利用 PDF.js 的
AnnotationStorage
和事件系统 (EventBus
)。 - 监听标注变化:
- PDF.js 在添加、修改或删除标注时,会通过
AnnotationStorage
或相关的编辑器(如AnnotationEditorLayer
)触发事件。你需要找到合适的事件来监听。可能相关的事件有annotationeditorstateschanged
或通过AnnotationStorage
的onSetModified
等钩子。 - 为两个
PDFViewer
实例(或它们关联的AnnotationLayer
/AnnotationEditorLayer
)添加事件监听器。
- PDF.js 在添加、修改或删除标注时,会通过
- 获取变化的标注数据:
- 当监听到一个视图(例如 Top 视图)的标注发生变化时,你需要获取这个变化标注的完整数据(类型、位置、内容、ID 等)。可以使用
AnnotationStorage.getValues()
或其他相关 API。
- 当监听到一个视图(例如 Top 视图)的标注发生变化时,你需要获取这个变化标注的完整数据(类型、位置、内容、ID 等)。可以使用
- 在另一个视图中应用变化:
- 获取到 Top 视图变化的标注数据后,你需要调用 Bottom 视图对应的
AnnotationStorage
的 API(例如setValue
或更底层的添加/更新方法)来应用这个变化。 - 反之亦然,当 Bottom 视图发生变化时,同步到 Top 视图。
- 获取到 Top 视图变化的标注数据后,你需要调用 Bottom 视图对应的
- 避免无限循环:
- 一个关键问题是防止同步操作本身触发新的同步事件,导致无限循环(A 更新 -> 触发 A 事件 -> 同步到 B -> B 更新 -> 触发 B 事件 -> 同步到 A ...)。
- 实现方法:
- 标记来源: 在触发同步操作时,添加一个标记(例如,一个临时变量或事件对象中的属性),表明这次更新是来自同步操作。在事件监听器中检查这个标记,如果是来自同步的更新,则不再触发对另一个视图的同步。
- 比较数据: 在尝试同步前,先获取目标视图中对应 ID 的标注数据,如果数据已经一致,则无需更新。
javascript// 简化示例:标注同步逻辑 (需要放在合适的初始化位置) const annotationStorageTop = pdfViewerTop.annotationStorage; // 获取存储对象 const annotationStorageBottom = pdfViewerBottom.annotationStorage; let isSyncing = false; // 用于防止无限循环的标志 function syncAnnotations(sourceStorage, targetStorage, sourceViewer) { if (isSyncing) return; // 如果正在同步中,则忽略 isSyncing = true; sourceStorage.getAll().then(annotations => { // 获取源存储的所有标注 if (annotations) { for (const key in annotations) { // key 通常是 annotation ID const value = annotations[key]; // 使用 setValue 更新目标存储,它通常会处理添加和修改 // 注意:直接使用 setValue 可能效率不高,更好的方式是只同步变化的标注 // PDF.js 内部可能有更精细的事件或API来获取增量变化 targetStorage.setValue(key, value).catch(err => { console.error("Sync error:", err); }); } // 可能还需要处理删除的情况:检查目标存储中存在但源存储中不存在的标注 } }).catch(err => { console.error("Error getting annotations for sync:", err); }).finally(() => { isSyncing = false; // 重置标志 // 可能需要强制目标视图重新渲染标注层 // targetViewer.eventBus.dispatch('annotationlayerrendered', { source: targetViewer }); // 或其他触发重绘的方法 }); } // 监听标注修改事件 (需要找到正确的事件和方式) // 假设 AnnotationStorage 提供了 'setmodified' 或类似事件 // 或者监听 EventBus 上的特定事件 // 以下为伪代码,具体事件名和方式需查阅 PDF.js 源码或文档 /* annotationStorageTop.on('setmodified', (key, value) => { if (!isSyncing) { console.log('Top modified, syncing to Bottom'); syncAnnotations(annotationStorageTop, annotationStorageBottom, pdfViewerBottom); // 或者更精细地只同步变化的 key/value // targetStorage.setValue(key, value).finally(() => { isSyncing = false; }); } }); annotationStorageBottom.on('setmodified', (key, value) => { if (!isSyncing) { console.log('Bottom modified, syncing to Top'); syncAnnotations(annotationStorageBottom, annotationStorageTop, pdfViewerTop); // 或者更精细地只同步变化的 key/value // targetStorage.setValue(key, value).finally(() => { isSyncing = false; }); } }); */ // 另一个可能的方式是监听 EventBus 上的 Annotation Editor 相关事件 // eventBusTop.on('annotationeditorstateschanged', (data) => { /* ...同步逻辑... */ }); // eventBusBottom.on('annotationeditorstateschanged', (data) => { /* ...同步逻辑... */ });
- 这是最核心和复杂的部分。你需要利用 PDF.js 的
-
处理滚动和缩放(可选同步):
- 你可能还希望在一个视图中滚动或缩放时,另一个视图也能同步。
- 监听一个视图的滚动 (
scroll
) 和缩放 (scalechanged
) 事件。 - 获取当前的滚动位置 (
scrollTop
,scrollLeft
) 或缩放比例。 - 在另一个视图上设置相应的滚动位置或缩放比例。
- 同样需要注意避免无限循环。
挑战与注意事项:
- 复杂性: 这涉及到对 PDF.js 内部工作原理(特别是视图管理、事件系统和标注存储)的深入理解和修改。
- PDF.js 版本: PDF.js 的 API 和内部结构可能会随版本更新而变化,你需要针对你使用的版本进行开发,并注意后续版本的兼容性问题。
- 性能: 创建两个完整的
PDFViewer
实例并实时同步标注可能会对性能产生影响,尤其是在标注数量很多或 PDF 页面很复杂时。需要进行测试和优化。 - 事件监听: 找到精确监听标注"已完成"变化的事件可能比较棘手,需要仔细研究
AnnotationLayer
,AnnotationEditorLayer
,AnnotationStorage
以及EventBus
的相关代码和事件。可能需要监听多个事件或组合使用。 - 标注 ID: 确保两个视图中的标注使用相同的唯一 ID 是同步的基础。通常 PDF.js 会自动生成 ID,只要操作的是同一个
AnnotationStorage
或能正确传递 ID,应该没问题。
总结:
实现这个功能需要较强的 JavaScript 和 PDF.js 开发能力。你需要:
- 改造 HTML/CSS 实现上下布局。
- 在 JS 中创建和管理两个
PDFViewer
实例,加载同一文档。 - 深入研究 PDF.js 的标注存储和事件机制。
- 编写 JavaScript 代码来监听标注变化,并在两个实例间进行双向同步,同时处理好循环调用的问题。
建议先从简单的部分开始,比如先实现两个视图加载同一个 PDF,然后再逐步实现标注的单向同步,最后实现双向同步和防循环机制。仔细查阅 PDF.js 的文档和源码会非常有帮助。