@embedpdf-editor/chapter-snippet 1.0.0 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -43,7 +43,18 @@ import ChapterEmbedPDF from '@embedpdf-editor/chapter-snippet';
43
43
 
44
44
  发包构建会对 `dist/embedpdf-chapter.js` 做语法降级(去掉 `??` 等 ES2020 语法),Webpack / Vue CLI 默认可 parse。默认 `wasmUrl` 指向 jsDelivr 上的 `pdfium.wasm`,无需复制到 `public/`。
45
45
 
46
- 离线或内网部署时,可传 `wasmUrl: '/pdfium.wasm'`(自行把 `node_modules/@embedpdf-editor/chapter-snippet/dist/pdfium.wasm` 放到静态目录)。
46
+ 离线或内网部署时,可传 `wasmUrl`(**init 顶层**,与 `options` 同级):
47
+
48
+ ```js
49
+ // 相对路径:自行把 dist/pdfium.wasm 放到静态目录
50
+ wasmUrl: '/pdfium.wasm',
51
+
52
+ // 自有 OSS / CDN(完整 HTTPS 地址,示例)
53
+ wasmUrl:
54
+ 'https://hep-editor.oss-cn-beijing.aliyuncs.com/public/editor-public/js/pdfium.wasm',
55
+ ```
56
+
57
+ 详见 [docs/get-started/01-installation.md](../../docs/get-started/01-installation.md)。
47
58
 
48
59
  `@embedpdf-editor/chapter-snippet/webpack` 为**可选**辅助(仅 monorepo Vue 2.6 解析、或自定义 devServer COOP/COEP),普通用户不必使用。
49
60
 
@@ -132,6 +143,48 @@ viewer?.addEventListener(CHAPTER_SNIPPET_EVENTS.ready, (event) => {
132
143
 
133
144
  旧版本的 `editorInput` 仍可使用,但新代码应改为 `options`,并把 `features` 写在 `options.features` 或顶层 `features`。
134
145
 
146
+ ### PDF 三步加载(Vue 2 推荐)
147
+
148
+ | 步骤 | 说明 |
149
+ | --- | --- |
150
+ | 1 | `manifest.chapters`:仅页码;`segmentPageThreshold` 写在章节上 |
151
+ | 2 | `chapterPdfLoader.loadChapterUrls`:按章 `getOneChap`,**每章只调一次** |
152
+ | 3 | `chapterPdfLoader.openPdf`(可选):解密等;省略则直接打开 `ctx.url` |
153
+
154
+ ```js
155
+ options: {
156
+ manifest: {
157
+ chapters: [
158
+ {
159
+ chapterId: item._id,
160
+ title: item.title,
161
+ globalPageRange: [item.startPage, item.endPage],
162
+ localPageRange: [0, item.endPage - item.startPage],
163
+ segmentPageThreshold: item.page,
164
+ },
165
+ ],
166
+ },
167
+ chapterPdfLoader: {
168
+ async loadChapterUrls(chapter) {
169
+ const res = await getOneChap(chapter.chapterId);
170
+ if (!res.success) throw new Error(res.message);
171
+ const raw = res.data.resourceUrl;
172
+ return Array.isArray(raw) ? raw : raw ? [raw] : [];
173
+ },
174
+ },
175
+ notes: { /* ... */ },
176
+ bookmarks: { /* ... */ },
177
+ },
178
+ ```
179
+
180
+ 勿在 loader 外再请求章节详情;勿把 `segmentPageThreshold` 放进 `source`。
181
+
182
+ [03-manifest.md](../../docs/get-started/03-manifest.md) · [12-segmented-pdf-and-per-chapter-storage.md](../../docs/get-started/12-segmented-pdf-and-per-chapter-storage.md)
183
+
184
+ ### 按章持久化
185
+
186
+ `options.notes` / `options.bookmarks` 回调里的 `chapterId`、`localPageIndex` 与单 URL 章相同。划线备份用 `exportChapterAnnotations`,JSON 键为 `chapters[chapterId]`(导出 markup 时会拉全部分段)。
187
+
135
188
  ### `ChapterViewerOptions`(与 React / Vue3 一致)
136
189
 
137
190
  | 字段 | 说明 |
@@ -155,7 +208,7 @@ viewer?.addEventListener(CHAPTER_SNIPPET_EVENTS.ready, (event) => {
155
208
  | `notes` | `marker.renderIcon`、`renderMenuActions`、`highlightColor` |
156
209
  | `zoom` | `pageWidth`、`min` / `max` / `enabled`;实际上限不超过 `[data-chapter-scroll-viewport]` 宽度,resize 时自动 clamp |
157
210
  | `scrollViewport` | `background`(默认 `#f1f5f9`),`[data-chapter-scroll-viewport]` 背景 |
158
- | `selectionToolbar` | `hiddenBuiltinActions`、`extraActions`;扩展动作监听 `selectionExtraAction` 事件 |
211
+ | `selectionToolbar` | `hiddenBuiltinActions`(含 `copy`)、`renderCopyIcon`、`extraActions`;扩展动作监听 `selectionExtraAction` 事件 |
159
212
 
160
213
  ```js
161
214
  features: {
@@ -182,12 +235,27 @@ features: {
182
235
  },
183
236
  },
184
237
  selectionToolbar: {
238
+ // 复制默认开启,浮窗最左侧;隐藏:hiddenBuiltinActions: ['copy']
239
+ renderCopyIcon: () => {
240
+ const span = document.createElement('span');
241
+ span.textContent = '📋';
242
+ span.setAttribute('aria-hidden', 'true');
243
+ return span;
244
+ },
185
245
  extraActions: [{ id: 'cite', label: '引用', order: 10 }],
186
246
  },
187
247
  },
188
248
  ```
189
249
 
190
- `renderMenu` / `renderIcon` 在 snippet(Preact)内执行,请返回 **DOM 或 Preact 节点**;Vue 2 宿主不要用 `h()` 直接塞进 Shadow DOM。
250
+ 划词后点**复制**会将选中文本写入剪贴板。程序化:
251
+
252
+ ```js
253
+ import { copyTextToClipboard } from '@embedpdf-editor/chapter-snippet';
254
+
255
+ await copyTextToClipboard('文本');
256
+ ```
257
+
258
+ `renderMenu` / `renderIcon` / `renderCopyIcon` 在 snippet(Preact)内执行,请返回 **DOM 或 Preact 节点**;Vue 2 宿主不要用 `h()` 直接塞进 Shadow DOM。
191
259
 
192
260
  ## 事件
193
261
 
@@ -247,11 +315,21 @@ import {
247
315
  | 选项 | 说明 |
248
316
  | --- | --- |
249
317
  | `mode` | `replace` 清空后导入;`merge` 合并 |
250
- | `ensureChapterLoaded` | 默认 true,导入 markup 前打开 PDF |
318
+ | `ensureChapterLoaded` | 默认 true;含 markup 的分段章会加载 **全部段** 再合并页码 |
251
319
  | `bookmarks` / `notes` / `markup` | 默认 true,可关闭某一类 |
252
320
  | `persistNotes` / `persistBookmarks` | 导入后写回业务存储 |
253
321
 
254
- 示例见 `examples/chapter-viewer-demo-vue2/src/components/AnnotationsDemoBar.vue`。
322
+ 示例见 `examples/chapter-viewer-demo-vue2/src/components/AnnotationsDemoBar.vue`。详见 [10-annotations-io.md](../../docs/get-started/10-annotations-io.md)。
323
+
324
+ ## 教程索引
325
+
326
+ | 主题 | 文档 |
327
+ | --- | --- |
328
+ | 目录 | [docs/get-started/README.md](../../docs/get-started/README.md) |
329
+ | `wasmUrl`(含 OSS 示例) | [01-installation.md](../../docs/get-started/01-installation.md) |
330
+ | 划词复制 | [07-selection-toolbar.md](../../docs/get-started/07-selection-toolbar.md) |
331
+ | 事件常量 | [11-events-callbacks-and-component-api.md](../../docs/get-started/11-events-callbacks-and-component-api.md) |
332
+ | 分段 + 按章存储 | [12-segmented-pdf-and-per-chapter-storage.md](../../docs/get-started/12-segmented-pdf-and-per-chapter-storage.md) |
255
333
 
256
334
  ## 与 React / Vue3 渲染器的区别
257
335
 
@@ -277,3 +355,4 @@ import {
277
355
  | 只显示空白 | 确认宿主元素有高度,且没有被父容器 `overflow`/flex 布局压到 0 |
278
356
  | 划词后没有笔记弹窗 | 监听 `chapter-note-request-create`,保存后调用 `detail.complete(noteId)` |
279
357
  | Vue/Vite 开发环境异常预构建 | 使用 `chapterSnippetViteResolve()`,确保 snippet 和 PDFium 引擎没有被 optimizeDeps 预构建 |
358
+ | 分段章存了 `#sN` | 业务层只用 `chapterId`;引擎段 ID 勿写入库 |
@@ -110,6 +110,11 @@ export declare interface ChapterDescriptor {
110
110
  * PDF 来源。可省略:须在 ChapterManager 配置 `chapterPdfLoader` 中统一加载。
111
111
  */
112
112
  source?: ChapterSource;
113
+ /**
114
+ * 章内多 PDF 分段时,每段最多页数(如 5 表示 13 页 → 3 段)。
115
+ * URL 由 `chapterPdfLoader.loadChapterUrls` 按章拉取,**不要**写在 `source` 里。
116
+ */
117
+ segmentPageThreshold?: number;
113
118
  encrypted?: boolean;
114
119
  /**
115
120
  * 仅当 OverlapOwnerStrategy = 'explicit' 时使用:
@@ -140,8 +145,16 @@ declare interface ChapterManagerCapability {
140
145
  getVirtualPageMap(): VirtualPageMap;
141
146
  /** ChapterScrollPlugin 在可见页位变化时调用,用以驱动按需加载 */
142
147
  setVisibleGlobalPages(visiblePageIndices: number[]): void;
143
- /** 显式触发某章节加载(如点击章节标题跳转时) */
148
+ /** 显式触发某章节加载(分段章节仅加载第 0 段) */
144
149
  ensureChapterLoaded(chapterId: string): Promise<ChapterLoadStatus>;
150
+ /** 加载章内指定分段 PDF */
151
+ ensureSegmentLoaded(chapterId: string, segmentIndex: number): Promise<ChapterLoadStatus>;
152
+ /** 加载章内全部分段(导出划线等) */
153
+ ensureAllSegmentsLoaded(chapterId: string): Promise<ChapterLoadStatus>;
154
+ /** 将章内 localPageIndex 映射为 documentManager 的 documentId + 段内页码 */
155
+ resolvePageDocument(chapterId: string, localPageIndex: number): ChapterPageDocumentRef | null;
156
+ getSegmentPlan(chapterId: string): ChapterSegmentPlan | null;
157
+ isSegmentLoaded(chapterId: string, segmentIndex: number): boolean;
145
158
  /** 状态查询 */
146
159
  getChapterStatus(chapterId: string): ChapterLoadStatus;
147
160
  getChapter(chapterId: string): ChapterDescriptor | null;
@@ -156,10 +169,8 @@ declare interface ChapterManagerCapability {
156
169
  /**
157
170
  * 章节生命周期管理:以章节为粒度懒加载/预取/卸载 PDF 文件,并屏蔽密码协议细节。
158
171
  *
159
- * 关键不变量:
160
- * - 每个章节对 DocumentManager 用 `documentId === chapterId`,从而保持 1:1 缓存。
161
- * - 视觉上的「activeDocumentId」由本插件根据当前可见页位维护;上层无须感知。
162
- * - 渲染层最终始终通过 `(chapterId, localPageIndex)` 索引到 owner 章节的 PDF 文档。
172
+ * - 单 URL:`documentId === chapterId`
173
+ * - 多段 URL:`documentId === chapterId#s{index}`,滚动时按段加载
163
174
  */
164
175
  export declare class ChapterManagerPlugin extends BasePlugin<ChapterManagerPluginConfig, ChapterManagerCapability> {
165
176
  static readonly id: "chapter-manager";
@@ -171,15 +182,14 @@ export declare class ChapterManagerPlugin extends BasePlugin<ChapterManagerPlugi
171
182
  private manifest;
172
183
  private overlapStrategy;
173
184
  private virtualPageMap;
174
- /** 每个章节当前状态(in-memory;不需要 redux 同步) */
175
185
  private readonly chapterStatus;
176
- /** 章节最近一次进入视口或被显式请求的时间戳,用于卸载判断 */
177
186
  private readonly chapterLastUsed;
178
- /** 章节密码累计尝试次数 */
179
187
  private readonly passwordAttempts;
180
- /** 等待 ensureChapterLoaded 完成的 Promise 列表(按章节去重) */
181
- private readonly pendingLoadPromises;
182
- /** unload tick 句柄 */
188
+ private readonly pendingChapterLoadPromises;
189
+ private readonly pendingSegmentLoadPromises;
190
+ /** 步骤 2:`loadChapterUrls` 按章缓存 */
191
+ private readonly chapterUrlsCache;
192
+ private readonly pendingChapterUrlsPromises;
183
193
  private unloadTimer;
184
194
  private documentManagerUnsubs;
185
195
  constructor(id: string, registry: PluginRegistry);
@@ -187,16 +197,21 @@ export declare class ChapterManagerPlugin extends BasePlugin<ChapterManagerPlugi
187
197
  protected buildCapability(): ChapterManagerCapability;
188
198
  destroy(): void;
189
199
  private setManifestInternal;
190
- /** manifest 就绪后预取前 N 章(不依赖滚动视口是否已算出可见页) */
191
200
  private eagerPrefetchFromManifest;
192
201
  private findChapter;
193
- private isOwnedChapter;
202
+ private getSegmentPlanForChapter;
203
+ private isSegmentDocumentOpen;
204
+ private syncChapterStatusFromDocuments;
194
205
  private handleVisibleChange;
195
206
  private collectIdleChapters;
196
207
  ensureChapterLoaded(chapterId: string): Promise<ChapterLoadStatus>;
208
+ ensureAllSegmentsLoaded(chapterId: string): Promise<ChapterLoadStatus>;
209
+ ensureSegmentLoaded(chapterId: string, segmentIndex: number): Promise<ChapterLoadStatus>;
210
+ private ensureSingleDocumentChapter;
211
+ private resolveChapterUrls;
212
+ private openPayloadFromUrl;
197
213
  private resolvePdfPayload;
198
- private startLoad;
199
- /** 阻塞等待该章节状态进入 loaded / error / password-required / closed */
214
+ private startLoadSegment;
200
215
  private waitForTerminalStatus;
201
216
  private handleDocumentError;
202
217
  private closeChapter;
@@ -238,6 +253,26 @@ export declare type ChapterNoteRequestEditDetail = {
238
253
  anchor: NoteAnchor;
239
254
  };
240
255
 
256
+ declare interface ChapterPageDocumentRef {
257
+ documentId: string;
258
+ pageIndex: number;
259
+ }
260
+
261
+ /**
262
+ * 打开某一段 PDF 时的上下文(步骤 2 已拿到 urls,步骤 3 可选处理)。
263
+ */
264
+ declare interface ChapterPdfLoadContext {
265
+ chapter: ChapterDescriptor;
266
+ /** 当前要打开的段,0-based */
267
+ segmentIndex: number;
268
+ /** 该章总段数 */
269
+ segmentCount: number;
270
+ /** 步骤 2:`loadChapterUrls` 返回的完整列表 */
271
+ urls: string[];
272
+ /** 当前段对应 URL:`urls[segmentIndex]` */
273
+ url: string;
274
+ }
275
+
241
276
  /**
242
277
  * Backend-supplied chapter contract.
243
278
  *
@@ -253,6 +288,22 @@ export declare type ChapterPdfPayload = {
253
288
  buffer: ArrayBuffer;
254
289
  };
255
290
 
291
+ declare interface ChapterSegmentInfo {
292
+ index: number;
293
+ /** 静态 manifest 有值;动态 loader 时为空,打开段时再解析 URL */
294
+ url: string;
295
+ localPageStart: number;
296
+ localPageEnd: number;
297
+ documentId: string;
298
+ }
299
+
300
+ declare interface ChapterSegmentPlan {
301
+ chapterId: string;
302
+ threshold: number;
303
+ pageCount: number;
304
+ segments: ChapterSegmentInfo[];
305
+ }
306
+
256
307
  /** 划词工具栏扩展操作事件 */
257
308
  export declare type ChapterSelectionActionDetail = {
258
309
  actionId: string;
@@ -272,6 +323,14 @@ export declare type ChapterSource = {
272
323
  /** 按章预处理:拉取、解密、转换后返回 url 或 buffer */
273
324
  | {
274
325
  load: () => Promise<ChapterPdfPayload>;
326
+ }
327
+ /**
328
+ * @deprecated 请用章节级 `segmentPageThreshold` + `chapterPdfLoader.loadChapterUrls`。
329
+ * 静态多 URL 时:`urls.length` 须为 ceil(章页数 / segmentPageThreshold)。
330
+ */
331
+ | {
332
+ urls: string[];
333
+ segmentPageThreshold: number;
275
334
  };
276
335
 
277
336
  declare interface ChapterStatusEvent {
@@ -294,6 +353,8 @@ export declare type ChapterTreeInput = {
294
353
  startPage: number;
295
354
  endPage: number;
296
355
  source?: ChapterSource;
356
+ /** 章内分段阈值;URL 由 `chapterPdfLoader.loadChapterUrls` 拉取 */
357
+ segmentPageThreshold?: number;
297
358
  encrypted?: boolean;
298
359
  children?: ChapterTreeInput[];
299
360
  };
@@ -429,6 +490,9 @@ export declare type ContainerInitConfig = ChapterViewerContainerConfig & {
429
490
  target: Element;
430
491
  };
431
492
 
493
+ /** 将划选文本写入系统剪贴板(Clipboard API,失败时回退 execCommand) */
494
+ export declare function copyTextToClipboard(text: string): Promise<boolean>;
495
+
432
496
  /** 生成插件列表 + 规范化后的 features(供 EmbedPDF / PdfChapterViewport 使用) */
433
497
  export declare function createChapterViewerBundle(input: ChapterViewerOptions | CreateChapterViewerEditorOptionsInput): {
434
498
  plugins: PluginBatchRegistration<any, any, any, any>[];
@@ -557,17 +621,32 @@ declare interface HoverBookmarkUiConfig_2 {
557
621
  }
558
622
 
559
623
  /**
560
- * 全局章节 PDF 加载器:在打开章节前由引擎调用,用于预处理/拉取 PDF。
624
+ * 章节 PDF 加载(推荐三步;`loadPdf` 仍兼容旧版单步写法)。
561
625
  *
562
- * 优先级(`ChapterManagerPlugin.resolvePdfPayload`):
563
- * 1. `chapter.source.url` / `source.buffer` 直接使用
564
- * 2. `chapter.source.load()` 按章异步,返回 `{ url }` `{ buffer }`
565
- * 3. `chapterPdfLoader.loadPdf(chapter)` 全局统一逻辑(鉴权、解密、转 blob URL 等)
626
+ * | 步骤 | 方法 | 说明 |
627
+ * | --- | --- | --- |
628
+ * | 1 | manifest 章节树 | `chapterId`、页码、`segmentPageThreshold` 等,**无 URL** |
629
+ * | 2 | `loadChapterUrls(chapter)` | 按章请求详情,返回 URL 列表(分段时多项) |
630
+ * | 3 | `openPdf(ctx)`(可选) | 对 `ctx.url` 解密/下载;省略则直接用 `ctx.url` 打开 |
566
631
  *
567
- * 适合:所有章节走同一套 API、在内存中解密后再以 buffer 打开、动态签名 URL 等。
632
+ * 仍可使用 `loadPdf(chapter, segmentIndex)` 单步实现(等价于 2+3 合并)。
568
633
  */
569
634
  export declare interface IChapterPdfLoader {
570
- loadPdf(chapter: ChapterDescriptor): Promise<ChapterPdfPayload>;
635
+ /**
636
+ * 步骤 2:按章拉取 PDF 地址列表。
637
+ * 单 PDF 章节返回长度为 1 的数组;分段章节返回与 `ceil(页数/threshold)` 一致的多项。
638
+ */
639
+ loadChapterUrls?(chapter: ChapterDescriptor): Promise<string[]>;
640
+ /**
641
+ * 步骤 3(可选):将步骤 2 的 URL 转为可打开的 payload。
642
+ * 未实现时引擎使用 `{ url: ctx.url }`。
643
+ */
644
+ openPdf?(ctx: ChapterPdfLoadContext): Promise<ChapterPdfPayload>;
645
+ /**
646
+ * @deprecated 单步加载;新代码请用 `loadChapterUrls` + 可选 `openPdf`。
647
+ * @param segmentIndex 章内分段索引;单 URL 章节为 0 或省略
648
+ */
649
+ loadPdf?(chapter: ChapterDescriptor, segmentIndex?: number): Promise<ChapterPdfPayload>;
571
650
  }
572
651
 
573
652
  /**