@hyebook/vue3-adapter 2.3.9 → 2.3.11

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.
@@ -1,13 +1,40 @@
1
1
  import { PlayerEngine } from "./engine";
2
2
  const EBOOK_PLAYER_STYLE_ID = "hy-ebook-lite-player-style";
3
- const EBOOK_PLAYER_STYLE_VERSION = "0.2.0";
3
+ const EBOOK_PLAYER_STYLE_VERSION = "0.2.2";
4
+ const JUMP_SCROLL_FOCUS_DELAY_MS = 360;
5
+ const JUMP_FOCUS_ANIMATION_MS = 560;
6
+ const BOOKMARK_JUMP_REVEAL_TOP_OFFSET_PX = 28;
4
7
  const HIGHLIGHT_ICON_SVG = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M3 22V12.5H6V8.5H18V12.5H21V22H3Z" stroke="#333333" stroke-width="2" stroke-linejoin="round"/><path d="M8.5 8.5V4L15.5 2V8.5" stroke="#333333" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
5
8
  const NOTE_ICON_SVG = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M15.4998 4.49951L19.4998 8.4995" stroke="#333333" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M3.99977 15.9995L17.9997 2L21.9998 5.9995L7.99975 19.9995L2.99976 20.9995L3.99977 15.9995Z" stroke="#333333" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.49976 15.9995L7.99975 19.4995" stroke="#333333" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M6.49976 17.4995L17.4998 6.4995" stroke="#333333" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
6
9
  const BOOKMARK_ICON_SVG = `<svg width="30" height="40" viewBox="0 0 30 40" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M0 3C0 1.34315 1.34315 0 3 0H27C28.6569 0 30 1.34315 30 3V38.1989C30 38.9837 29.1374 39.4627 28.4713 39.0477L15 30.6562L1.52873 39.0477C0.862627 39.4627 0 38.9837 0 38.1989L0 3Z" fill="#4FCEBB"/></svg>`;
10
+ const BACK_TO_TOP_ICON_SVG = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 18V6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M7 11L12 6L17 11" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
7
11
  const EBOOK_PLAYER_CSS = `
8
12
  .hyepl-root{position:relative;max-width:100%;margin:0 auto;color:#0f172a;font-size:14px;line-height:1.7}
9
13
  .hyepl-root ::selection{background:#7DE2D3}
10
14
  .hyepl-root ::-moz-selection{background:#7DE2D3}
15
+ .hyepl-load-warning{position:absolute;top:12px;right:12px;z-index:10080;max-width:min(420px,calc(100% - 24px));display:none;border:1px solid #f5c2c7;background:#fff5f5;color:#7f1d1d;border-radius:10px;box-shadow:0 10px 24px rgba(15,23,42,.12);padding:8px 10px}
16
+ .hyepl-load-warning.show{display:block}
17
+ .hyepl-load-warning-header{display:flex;align-items:center;gap:8px}
18
+ .hyepl-load-warning-title{font-size:12px;font-weight:600;line-height:1.4}
19
+ .hyepl-load-warning-actions{margin-left:auto;display:inline-flex;align-items:center;gap:6px}
20
+ .hyepl-load-warning-btn{border:1px solid #f1b3b3;background:#fff;color:#7f1d1d;border-radius:6px;height:24px;padding:0 8px;font-size:12px;line-height:1;cursor:pointer}
21
+ .hyepl-load-warning-close{width:24px;padding:0}
22
+ .hyepl-load-warning-message{margin:8px 0 0;font-size:12px;line-height:1.5;color:#7f1d1d;white-space:pre-wrap;word-break:break-word;display:none;max-height:180px;overflow:auto}
23
+ .hyepl-load-warning.expanded .hyepl-load-warning-message{display:block}
24
+ .hyepl-input-backdrop{position:fixed;inset:0;display:none;align-items:center;justify-content:center;background:rgba(15,23,42,.45);z-index:10090;padding:16px}
25
+ .hyepl-input-backdrop.show{display:flex}
26
+ .hyepl-input-dialog{width:min(520px,100%);background:#fff;border:1px solid #cbd5e1;border-radius:12px;padding:14px;display:grid;gap:10px;box-shadow:0 16px 34px rgba(15,23,42,.28)}
27
+ .hyepl-input-title{margin:0;font-size:14px;font-weight:600;color:#0f172a}
28
+ .hyepl-input-textarea{width:100%;min-height:120px;resize:vertical;border:1px solid #94a3b8;border-radius:8px;padding:8px 10px;background:#fff;color:#0f172a;box-sizing:border-box;font-family:inherit;font-size:14px;line-height:1.5}
29
+ .hyepl-input-error{min-height:18px;margin:0;color:#b91c1c;font-size:12px}
30
+ .hyepl-input-actions{display:flex;justify-content:flex-end;gap:8px}
31
+ .hyepl-input-btn{height:34px;padding:0 12px;border-radius:8px;border:1px solid #94a3b8;background:#fff;color:#0f172a;cursor:pointer}
32
+ .hyepl-input-btn.primary{border-color:#0b7285;background:#0b7285;color:#fff}
33
+ .hyepl-back-to-top{position:absolute;right:16px;bottom:16px;z-index:10060;width:52px;height:52px;border:1px solid #d5dde8;border-radius:999px;background:#ffffff;color:#0f172a;box-shadow:0 10px 28px rgba(15,23,42,.2);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;transition:background .2s ease,color .2s ease,border-color .2s ease}
34
+ .hyepl-back-to-top svg{width:22px;height:22px}
35
+ .hyepl-back-to-top:hover{background:#0f172a;color:#ffffff;border-color:#0f172a}
36
+ .hyepl-back-to-top:focus-visible{outline:2px solid #38bdf8;outline-offset:2px}
37
+ .hyepl-back-to-top.hidden{display:none}
11
38
  .hyepl-content{position:relative}
12
39
  .hyepl-page{margin:0 0 16px}
13
40
  .hyepl-page:last-child{margin-bottom:0}
@@ -16,6 +43,7 @@ const EBOOK_PLAYER_CSS = `
16
43
  .hyepl-block-text{position:relative;padding-right:18px;white-space:normal}
17
44
  .hyepl-block-text p,.hyepl-block-text h1,.hyepl-block-text h2,.hyepl-block-text h3,.hyepl-block-text h4,.hyepl-block-text blockquote{margin:0 0 .75em;line-height:1.7}
18
45
  .hyepl-block-text p:last-child,.hyepl-block-text h1:last-child,.hyepl-block-text h2:last-child,.hyepl-block-text h3:last-child,.hyepl-block-text h4:last-child,.hyepl-block-text blockquote:last-child{margin-bottom:0}
46
+ .hyepl-block-text em,.hyepl-block-text i,.hyepl-block-table em,.hyepl-block-table i{font-style:italic}
19
47
  .hyepl-block-text h1{font-size:24px;line-height:1.3}
20
48
  .hyepl-block-text h2{font-size:20px;line-height:1.35}
21
49
  .hyepl-block-text h3{font-size:16px;line-height:1.45}
@@ -48,10 +76,31 @@ const EBOOK_PLAYER_CSS = `
48
76
  .hyepl-toolbar-icon{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px}
49
77
  .hyepl-toolbar-icon svg{display:block;width:24px;height:24px}
50
78
  .hyepl-toolbar-text{font-size:14px;font-weight:700;color:#111827}
79
+
80
+ .hyepl-jump-focus{animation:hyepl-jump-focus 560ms ease-out}
81
+ .hyepl-jump-focus-mark{animation:hyepl-jump-focus-mark 560ms ease-out}
82
+
83
+ @keyframes hyepl-jump-focus{
84
+ 0%{filter:none;box-shadow:0 0 0 0 rgba(125,226,211,0)}
85
+ 35%{filter:brightness(1.08);box-shadow:0 0 0 4px rgba(125,226,211,.24)}
86
+ 100%{filter:none;box-shadow:0 0 0 0 rgba(125,226,211,0)}
87
+ }
88
+
89
+ @keyframes hyepl-jump-focus-mark{
90
+ 0%{opacity:1;filter:none}
91
+ 35%{opacity:.72;filter:brightness(1.2)}
92
+ 100%{opacity:1;filter:none}
93
+ }
94
+
95
+ @media (prefers-reduced-motion:reduce){
96
+ .hyepl-jump-focus,.hyepl-jump-focus-mark{animation:none}
97
+ }
51
98
  `;
52
99
  export class EBookPlayer {
53
100
  constructor(container, options) {
54
101
  this.runtimeDocument = null;
102
+ this.pendingTextInputResolver = null;
103
+ this.pendingTextInputMaxLength = 0;
55
104
  this.selectionDraft = null;
56
105
  this.loaded = false;
57
106
  this.destroyed = false;
@@ -60,6 +109,7 @@ export class EBookPlayer {
60
109
  };
61
110
  this.handleSelectionRepositionBound = () => {
62
111
  this.updateSelectionToolbarPosition();
112
+ this.updateBackToTopButtonPosition();
63
113
  };
64
114
  this.handleDocumentPointerDownBound = (event) => {
65
115
  this.handleDocumentPointerDown(event);
@@ -73,6 +123,37 @@ export class EBookPlayer {
73
123
  this.handleNoteClickBound = () => {
74
124
  void this.handleNoteClick();
75
125
  };
126
+ this.handleLoadWarningToggleBound = () => {
127
+ this.toggleLoadWarning();
128
+ };
129
+ this.handleLoadWarningCloseBound = () => {
130
+ this.hideLoadWarning();
131
+ };
132
+ this.handleTextInputCancelBound = () => {
133
+ this.finishTextInput(null);
134
+ };
135
+ this.handleTextInputConfirmBound = () => {
136
+ this.confirmTextInput();
137
+ };
138
+ this.handleTextInputBackdropPointerDownBound = (event) => {
139
+ if (event.target === this.textInputBackdrop) {
140
+ this.finishTextInput(null);
141
+ }
142
+ };
143
+ this.handleTextInputKeyDownBound = (event) => {
144
+ if (event.key === "Escape") {
145
+ event.preventDefault();
146
+ this.finishTextInput(null);
147
+ return;
148
+ }
149
+ if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
150
+ event.preventDefault();
151
+ this.confirmTextInput();
152
+ }
153
+ };
154
+ this.handleBackToTopClickBound = () => {
155
+ this.scrollToStoredPosition(0, "smooth");
156
+ };
76
157
  this.container = container;
77
158
  this.options = options;
78
159
  const runtimeProvider = createRuntimeProvider(options.provider, () => this.runtimeDocument);
@@ -87,6 +168,60 @@ export class EBookPlayer {
87
168
  this.content.className = "hyepl-content";
88
169
  this.selectionToolbar = document.createElement("div");
89
170
  this.selectionToolbar.className = "hyepl-selection-toolbar";
171
+ this.loadWarning = document.createElement("div");
172
+ this.loadWarning.className = "hyepl-load-warning";
173
+ const loadWarningHeader = document.createElement("div");
174
+ loadWarningHeader.className = "hyepl-load-warning-header";
175
+ const loadWarningTitle = document.createElement("span");
176
+ loadWarningTitle.className = "hyepl-load-warning-title";
177
+ loadWarningTitle.textContent = "加载数据异常";
178
+ const loadWarningActions = document.createElement("div");
179
+ loadWarningActions.className = "hyepl-load-warning-actions";
180
+ this.loadWarningToggleBtn = document.createElement("button");
181
+ this.loadWarningToggleBtn.type = "button";
182
+ this.loadWarningToggleBtn.className = "hyepl-load-warning-btn";
183
+ this.loadWarningCloseBtn = document.createElement("button");
184
+ this.loadWarningCloseBtn.type = "button";
185
+ this.loadWarningCloseBtn.className =
186
+ "hyepl-load-warning-btn hyepl-load-warning-close";
187
+ this.loadWarningCloseBtn.textContent = "×";
188
+ this.loadWarningCloseBtn.title = "关闭";
189
+ this.loadWarningCloseBtn.setAttribute("aria-label", "关闭加载异常提示");
190
+ this.loadWarningMessage = document.createElement("pre");
191
+ this.loadWarningMessage.className = "hyepl-load-warning-message";
192
+ loadWarningActions.append(this.loadWarningToggleBtn, this.loadWarningCloseBtn);
193
+ loadWarningHeader.append(loadWarningTitle, loadWarningActions);
194
+ this.loadWarning.append(loadWarningHeader, this.loadWarningMessage);
195
+ this.setLoadWarningExpanded(false);
196
+ this.textInputBackdrop = document.createElement("div");
197
+ this.textInputBackdrop.className = "hyepl-input-backdrop";
198
+ const textInputDialog = document.createElement("div");
199
+ textInputDialog.className = "hyepl-input-dialog";
200
+ this.textInputTitle = document.createElement("h4");
201
+ this.textInputTitle.className = "hyepl-input-title";
202
+ this.textInputTextarea = document.createElement("textarea");
203
+ this.textInputTextarea.className = "hyepl-input-textarea";
204
+ this.textInputError = document.createElement("p");
205
+ this.textInputError.className = "hyepl-input-error";
206
+ const textInputActions = document.createElement("div");
207
+ textInputActions.className = "hyepl-input-actions";
208
+ this.textInputCancelBtn = document.createElement("button");
209
+ this.textInputCancelBtn.type = "button";
210
+ this.textInputCancelBtn.className = "hyepl-input-btn";
211
+ this.textInputCancelBtn.textContent = "取消";
212
+ this.textInputConfirmBtn = document.createElement("button");
213
+ this.textInputConfirmBtn.type = "button";
214
+ this.textInputConfirmBtn.className = "hyepl-input-btn primary";
215
+ this.textInputConfirmBtn.textContent = "确认";
216
+ textInputActions.append(this.textInputCancelBtn, this.textInputConfirmBtn);
217
+ textInputDialog.append(this.textInputTitle, this.textInputTextarea, this.textInputError, textInputActions);
218
+ this.textInputBackdrop.append(textInputDialog);
219
+ this.backToTopBtn = document.createElement("button");
220
+ this.backToTopBtn.type = "button";
221
+ this.backToTopBtn.className = "hyepl-back-to-top";
222
+ this.backToTopBtn.innerHTML = BACK_TO_TOP_ICON_SVG;
223
+ this.backToTopBtn.title = "回到顶部";
224
+ this.backToTopBtn.setAttribute("aria-label", "回到顶部");
90
225
  this.highlightBtn = document.createElement("button");
91
226
  this.highlightBtn.type = "button";
92
227
  this.highlightBtn.className = "hyepl-toolbar-btn";
@@ -101,32 +236,51 @@ export class EBookPlayer {
101
236
  this.setToolbarButtonContent(this.noteBtn, NOTE_ICON_SVG, this.selectionOptions.noteButtonText);
102
237
  this.noteBtn.setAttribute("aria-label", this.selectionOptions.noteButtonText);
103
238
  this.selectionToolbar.append(this.highlightBtn, this.toolbarDivider, this.noteBtn);
104
- this.root.append(this.content, this.selectionToolbar);
239
+ this.root.append(this.content, this.selectionToolbar, this.loadWarning, this.textInputBackdrop);
105
240
  this.container.innerHTML = "";
106
241
  this.container.append(this.root);
242
+ if (!this.container.style.position) {
243
+ this.container.style.position = "relative";
244
+ }
245
+ this.container.append(this.backToTopBtn);
107
246
  this.highlightBtn.addEventListener("mousedown", this.preserveSelectionOnToolbarMouseDownBound);
108
247
  this.noteBtn.addEventListener("mousedown", this.preserveSelectionOnToolbarMouseDownBound);
109
248
  this.highlightBtn.addEventListener("click", this.handleHighlightClickBound);
110
249
  this.noteBtn.addEventListener("click", this.handleNoteClickBound);
250
+ this.loadWarningToggleBtn.addEventListener("click", this.handleLoadWarningToggleBound);
251
+ this.loadWarningCloseBtn.addEventListener("click", this.handleLoadWarningCloseBound);
252
+ this.textInputCancelBtn.addEventListener("click", this.handleTextInputCancelBound);
253
+ this.textInputConfirmBtn.addEventListener("click", this.handleTextInputConfirmBound);
254
+ this.textInputBackdrop.addEventListener("pointerdown", this.handleTextInputBackdropPointerDownBound);
255
+ this.textInputTextarea.addEventListener("keydown", this.handleTextInputKeyDownBound);
256
+ this.backToTopBtn.addEventListener("click", this.handleBackToTopClickBound);
111
257
  document.addEventListener("selectionchange", this.handleSelectionChangeBound);
112
258
  document.addEventListener("pointerdown", this.handleDocumentPointerDownBound);
113
259
  window.addEventListener("scroll", this.handleSelectionRepositionBound, true);
114
260
  window.addEventListener("resize", this.handleSelectionRepositionBound);
261
+ this.updateBackToTopButtonPosition();
115
262
  }
116
263
  async load(doc) {
117
264
  if (doc) {
118
265
  this.runtimeDocument = cloneEbookDoc(doc);
119
266
  }
120
- if (!this.options.provider && !this.runtimeDocument) {
121
- throw new Error("Provider is not configured. Call load(doc) when using EBookPlayer without provider.");
267
+ try {
268
+ if (!this.options.provider && !this.runtimeDocument) {
269
+ throw new Error("Provider is not configured. Call load(doc) when using EBookPlayer without provider.");
270
+ }
271
+ const loadedDoc = await this.engine.load();
272
+ this.loaded = true;
273
+ if (this.options.initialAnnotations) {
274
+ await this.engine.setAnnotations(this.options.initialAnnotations, "api");
275
+ }
276
+ this.renderDocument();
277
+ this.hideLoadWarning();
278
+ return loadedDoc;
122
279
  }
123
- const loadedDoc = await this.engine.load();
124
- this.loaded = true;
125
- if (this.options.initialAnnotations) {
126
- await this.engine.setAnnotations(this.options.initialAnnotations, "api");
280
+ catch (error) {
281
+ this.showLoadWarning(error);
282
+ throw error;
127
283
  }
128
- this.renderDocument();
129
- return loadedDoc;
130
284
  }
131
285
  destroy() {
132
286
  if (this.destroyed) {
@@ -142,6 +296,19 @@ export class EBookPlayer {
142
296
  this.noteBtn.removeEventListener("mousedown", this.preserveSelectionOnToolbarMouseDownBound);
143
297
  this.highlightBtn.removeEventListener("click", this.handleHighlightClickBound);
144
298
  this.noteBtn.removeEventListener("click", this.handleNoteClickBound);
299
+ this.loadWarningToggleBtn.removeEventListener("click", this.handleLoadWarningToggleBound);
300
+ this.loadWarningCloseBtn.removeEventListener("click", this.handleLoadWarningCloseBound);
301
+ this.textInputCancelBtn.removeEventListener("click", this.handleTextInputCancelBound);
302
+ this.textInputConfirmBtn.removeEventListener("click", this.handleTextInputConfirmBound);
303
+ this.textInputBackdrop.removeEventListener("pointerdown", this.handleTextInputBackdropPointerDownBound);
304
+ this.textInputTextarea.removeEventListener("keydown", this.handleTextInputKeyDownBound);
305
+ this.backToTopBtn.removeEventListener("click", this.handleBackToTopClickBound);
306
+ if (this.pendingTextInputResolver) {
307
+ this.finishTextInput(null);
308
+ }
309
+ if (this.backToTopBtn.parentNode === this.container) {
310
+ this.container.removeChild(this.backToTopBtn);
311
+ }
145
312
  if (this.root.parentNode === this.container) {
146
313
  this.container.removeChild(this.root);
147
314
  }
@@ -226,7 +393,7 @@ export class EBookPlayer {
226
393
  throw new Error("Bookmark content is required when prompt is disabled.");
227
394
  }
228
395
  else {
229
- content = this.promptTextWithLimit("请输入书签内容", current?.content || "", this.inputLimits.bookmarkMaxLength);
396
+ content = await this.promptTextWithLimit("请输入书签内容", current?.content || "", this.inputLimits.bookmarkMaxLength);
230
397
  }
231
398
  if (content === null) {
232
399
  return null;
@@ -236,6 +403,31 @@ export class EBookPlayer {
236
403
  this.renderDocument();
237
404
  return bookmark;
238
405
  }
406
+ async updateBookmark(bookmarkId, options = {}, source = "api") {
407
+ this.ensureLoaded();
408
+ const current = this.engine
409
+ .getBookmarks()
410
+ .find((item) => item.id === bookmarkId);
411
+ if (!current) {
412
+ return null;
413
+ }
414
+ let content = null;
415
+ if (typeof options.content === "string" && options.content.trim()) {
416
+ content = this.normalizeBookmarkContent(options.content);
417
+ }
418
+ else if (options.prompt === false) {
419
+ throw new Error("Bookmark content is required when prompt is disabled.");
420
+ }
421
+ else {
422
+ content = await this.promptTextWithLimit("请输入书签内容", current.content || "", this.inputLimits.bookmarkMaxLength);
423
+ }
424
+ if (content === null) {
425
+ return null;
426
+ }
427
+ const updated = await this.engine.updateBookmark(bookmarkId, { content }, source);
428
+ this.renderDocument();
429
+ return updated;
430
+ }
239
431
  async deleteBookmark(bookmarkId, source = "api") {
240
432
  this.ensureLoaded();
241
433
  const deleted = await this.engine.deleteBookmark(bookmarkId, source);
@@ -254,7 +446,13 @@ export class EBookPlayer {
254
446
  if (pageIndex === null) {
255
447
  return null;
256
448
  }
257
- this.scrollToBlock(target.pageId, target.blockId);
449
+ const blockNode = this.scrollToBlock(target.pageId, target.blockId);
450
+ const selector = `mark.hyepl-highlight[data-highlight-id="${this.escapeSelectorValue(highlightId)}"]`;
451
+ this.focusAfterJump({
452
+ selector,
453
+ focusClass: "hyepl-jump-focus-mark",
454
+ fallbackNode: blockNode,
455
+ });
258
456
  return pageIndex;
259
457
  }
260
458
  goToNote(noteId) {
@@ -267,7 +465,13 @@ export class EBookPlayer {
267
465
  if (pageIndex === null) {
268
466
  return null;
269
467
  }
270
- this.scrollToBlock(target.pageId, target.blockId);
468
+ const blockNode = this.scrollToBlock(target.pageId, target.blockId);
469
+ const selector = `mark.hyepl-note[data-note-id="${this.escapeSelectorValue(noteId)}"]`;
470
+ this.focusAfterJump({
471
+ selector,
472
+ focusClass: "hyepl-jump-focus-mark",
473
+ fallbackNode: blockNode,
474
+ });
271
475
  return pageIndex;
272
476
  }
273
477
  goToBookmark(bookmarkId) {
@@ -282,11 +486,30 @@ export class EBookPlayer {
282
486
  if (pageIndex === null) {
283
487
  return null;
284
488
  }
489
+ const bookmarkSelector = `.hyepl-bookmark-flag[data-bookmark-id="${this.escapeSelectorValue(bookmarkId)}"]`;
490
+ const bookmarkNode = this.content.querySelector(bookmarkSelector);
491
+ const fallbackNode = this.findBlockNode(target.pageId, target.blockId);
285
492
  if (Number.isFinite(target.scrollTop)) {
286
- this.scrollToStoredPosition(target.scrollTop);
493
+ this.scrollToStoredPosition(target.scrollTop - BOOKMARK_JUMP_REVEAL_TOP_OFFSET_PX);
494
+ this.focusAfterJump({
495
+ selector: bookmarkSelector,
496
+ fallbackNode,
497
+ });
287
498
  return pageIndex;
288
499
  }
289
- this.scrollToBlock(target.pageId, target.blockId);
500
+ if (bookmarkNode) {
501
+ this.scrollToNodeWithTopOffset(bookmarkNode, BOOKMARK_JUMP_REVEAL_TOP_OFFSET_PX);
502
+ }
503
+ else if (fallbackNode) {
504
+ this.scrollToNodeWithTopOffset(fallbackNode, BOOKMARK_JUMP_REVEAL_TOP_OFFSET_PX);
505
+ }
506
+ else {
507
+ this.scrollToBlock(target.pageId, target.blockId);
508
+ }
509
+ this.focusAfterJump({
510
+ selector: bookmarkSelector,
511
+ fallbackNode,
512
+ });
290
513
  return pageIndex;
291
514
  }
292
515
  onAnnotationCreate(listener) {
@@ -681,6 +904,7 @@ export class EBookPlayer {
681
904
  }
682
905
  const mark = document.createElement("mark");
683
906
  mark.className = "hyepl-highlight";
907
+ mark.dataset.highlightId = item.id;
684
908
  if (item.color) {
685
909
  mark.style.setProperty("--hyepl-highlight-color", item.color);
686
910
  }
@@ -995,6 +1219,22 @@ export class EBookPlayer {
995
1219
  this.selectionToolbar.classList.remove("show");
996
1220
  this.selectionDraft = null;
997
1221
  }
1222
+ showLoadWarning(error) {
1223
+ this.loadWarningMessage.textContent = resolveErrorMessage(error, "未知加载错误");
1224
+ this.loadWarning.classList.add("show");
1225
+ this.setLoadWarningExpanded(false);
1226
+ }
1227
+ hideLoadWarning() {
1228
+ this.loadWarning.classList.remove("show");
1229
+ this.setLoadWarningExpanded(false);
1230
+ }
1231
+ toggleLoadWarning() {
1232
+ this.setLoadWarningExpanded(!this.loadWarning.classList.contains("expanded"));
1233
+ }
1234
+ setLoadWarningExpanded(expanded) {
1235
+ this.loadWarning.classList.toggle("expanded", expanded);
1236
+ this.loadWarningToggleBtn.textContent = expanded ? "收起" : "详情";
1237
+ }
998
1238
  clearSelection() {
999
1239
  window.getSelection()?.removeAllRanges();
1000
1240
  }
@@ -1059,29 +1299,30 @@ export class EBookPlayer {
1059
1299
  this.hideSelectionToolbar();
1060
1300
  }
1061
1301
  async handleNoteClick() {
1062
- if (!this.selectionDraft) {
1302
+ const selectionDraft = this.selectionDraft;
1303
+ if (!selectionDraft) {
1063
1304
  return;
1064
1305
  }
1065
1306
  const defaultText = "";
1066
- const content = this.promptTextWithLimit("请输入笔记内容", defaultText, this.inputLimits.noteMaxLength);
1307
+ const content = await this.promptTextWithLimit("请输入笔记内容", defaultText, this.inputLimits.noteMaxLength);
1067
1308
  if (content === null) {
1068
1309
  return;
1069
1310
  }
1070
- const segments = this.selectionDraft.segments && this.selectionDraft.segments.length
1071
- ? this.selectionDraft.segments
1311
+ const segments = selectionDraft.segments && selectionDraft.segments.length
1312
+ ? selectionDraft.segments
1072
1313
  : [
1073
1314
  {
1074
- pageId: this.selectionDraft.pageId,
1075
- blockId: this.selectionDraft.blockId,
1076
- range: this.selectionDraft.range,
1077
- text: this.selectionDraft.text,
1315
+ pageId: selectionDraft.pageId,
1316
+ blockId: selectionDraft.blockId,
1317
+ range: selectionDraft.range,
1318
+ text: selectionDraft.text,
1078
1319
  },
1079
1320
  ];
1080
1321
  const firstSegment = segments[0];
1081
1322
  if (!firstSegment) {
1082
1323
  return;
1083
1324
  }
1084
- const mergedSelectedText = this.selectionDraft.text ||
1325
+ const mergedSelectedText = selectionDraft.text ||
1085
1326
  segments
1086
1327
  .map((segment) => segment.text)
1087
1328
  .filter(Boolean)
@@ -1113,6 +1354,41 @@ export class EBookPlayer {
1113
1354
  }
1114
1355
  return Math.max(320, Math.round(width));
1115
1356
  }
1357
+ updateBackToTopButtonPosition() {
1358
+ const hostRect = this.container.getBoundingClientRect();
1359
+ const hostWidth = this.container.clientWidth || hostRect.width;
1360
+ const hostHeight = this.container.clientHeight || hostRect.height;
1361
+ const buttonWidth = this.backToTopBtn.offsetWidth || 52;
1362
+ const buttonHeight = this.backToTopBtn.offsetHeight || 52;
1363
+ const margin = 16;
1364
+ if (hostWidth <= 0 || hostHeight <= 0) {
1365
+ this.backToTopBtn.classList.add("hidden");
1366
+ return;
1367
+ }
1368
+ const viewportLeft = 0;
1369
+ const viewportTop = 0;
1370
+ const viewportRight = window.innerWidth;
1371
+ const viewportBottom = window.innerHeight;
1372
+ const desiredViewportLeft = Math.min(hostRect.right - buttonWidth - margin, viewportRight - buttonWidth - margin);
1373
+ const desiredViewportTop = viewportBottom - buttonHeight - margin;
1374
+ const minViewportLeft = Math.max(viewportLeft + margin, hostRect.left + margin);
1375
+ const maxViewportLeft = Math.min(viewportRight - buttonWidth - margin, hostRect.right - buttonWidth - margin);
1376
+ const minViewportTop = Math.max(viewportTop + margin, hostRect.top + margin);
1377
+ const maxViewportTop = Math.min(viewportBottom - buttonHeight - margin, hostRect.bottom - buttonHeight - margin);
1378
+ if (maxViewportLeft < minViewportLeft || maxViewportTop < minViewportTop) {
1379
+ this.backToTopBtn.classList.add("hidden");
1380
+ return;
1381
+ }
1382
+ const clampedViewportLeft = Math.max(minViewportLeft, Math.min(desiredViewportLeft, maxViewportLeft));
1383
+ const clampedViewportTop = Math.max(minViewportTop, Math.min(desiredViewportTop, maxViewportTop));
1384
+ const localLeft = clampedViewportLeft - hostRect.left;
1385
+ const localTop = clampedViewportTop - hostRect.top;
1386
+ this.backToTopBtn.style.left = `${Math.round(localLeft)}px`;
1387
+ this.backToTopBtn.style.top = `${Math.round(localTop)}px`;
1388
+ this.backToTopBtn.style.right = "auto";
1389
+ this.backToTopBtn.style.bottom = "auto";
1390
+ this.backToTopBtn.classList.remove("hidden");
1391
+ }
1116
1392
  resolveSelectionToolbarOptions(options) {
1117
1393
  const highlightButtonText = typeof options?.highlightButtonText === "string" &&
1118
1394
  options.highlightButtonText.trim()
@@ -1150,24 +1426,43 @@ export class EBookPlayer {
1150
1426
  return Math.max(1, Math.floor(value));
1151
1427
  }
1152
1428
  promptTextWithLimit(title, defaultText, maxLength) {
1153
- let nextDefault = defaultText;
1154
- while (true) {
1155
- const raw = window.prompt(`${title}(最多${maxLength}字)`, nextDefault);
1156
- if (raw === null) {
1157
- return null;
1158
- }
1159
- const trimmed = raw.trim();
1160
- if (!trimmed) {
1161
- window.alert("内容不能为空");
1162
- nextDefault = "";
1163
- continue;
1164
- }
1165
- if (trimmed.length > maxLength) {
1166
- window.alert(`最多输入${maxLength}字,当前${trimmed.length}字。`);
1167
- nextDefault = trimmed.slice(0, maxLength);
1168
- continue;
1169
- }
1170
- return trimmed;
1429
+ if (this.pendingTextInputResolver) {
1430
+ this.finishTextInput(null);
1431
+ }
1432
+ this.pendingTextInputMaxLength = maxLength;
1433
+ this.textInputTitle.textContent = `${title}(最多${maxLength}字)`;
1434
+ this.textInputTextarea.value = defaultText;
1435
+ this.textInputError.textContent = "";
1436
+ this.textInputBackdrop.classList.add("show");
1437
+ window.setTimeout(() => {
1438
+ this.textInputTextarea.focus();
1439
+ this.textInputTextarea.setSelectionRange(this.textInputTextarea.value.length, this.textInputTextarea.value.length);
1440
+ }, 0);
1441
+ return new Promise((resolve) => {
1442
+ this.pendingTextInputResolver = resolve;
1443
+ });
1444
+ }
1445
+ confirmTextInput() {
1446
+ const raw = this.textInputTextarea.value || "";
1447
+ const trimmed = raw.trim();
1448
+ if (!trimmed) {
1449
+ this.textInputError.textContent = "内容不能为空";
1450
+ return;
1451
+ }
1452
+ if (trimmed.length > this.pendingTextInputMaxLength) {
1453
+ this.textInputError.textContent = `最多输入${this.pendingTextInputMaxLength}字,当前${trimmed.length}字。`;
1454
+ return;
1455
+ }
1456
+ this.finishTextInput(trimmed);
1457
+ }
1458
+ finishTextInput(value) {
1459
+ const resolver = this.pendingTextInputResolver;
1460
+ this.pendingTextInputResolver = null;
1461
+ this.pendingTextInputMaxLength = 0;
1462
+ this.textInputBackdrop.classList.remove("show");
1463
+ this.textInputError.textContent = "";
1464
+ if (resolver) {
1465
+ resolver(value);
1171
1466
  }
1172
1467
  }
1173
1468
  normalizeBookmarkContent(raw) {
@@ -1220,15 +1515,18 @@ export class EBookPlayer {
1220
1515
  }
1221
1516
  return { pageId, blockId };
1222
1517
  }
1223
- scrollToBlock(pageId, blockId) {
1518
+ findBlockNode(pageId, blockId) {
1224
1519
  const page = this.escapeSelectorValue(pageId);
1225
1520
  const block = this.escapeSelectorValue(blockId);
1226
- const node = this.content.querySelector(`[data-page-id="${page}"][data-block-id="${block}"]`);
1521
+ return this.content.querySelector(`[data-page-id="${page}"][data-block-id="${block}"]`);
1522
+ }
1523
+ scrollToBlock(pageId, blockId, behavior = "smooth") {
1524
+ const node = this.findBlockNode(pageId, blockId);
1227
1525
  if (!node) {
1228
- return false;
1526
+ return null;
1229
1527
  }
1230
- node.scrollIntoView({ block: "start" });
1231
- return true;
1528
+ node.scrollIntoView({ block: "start", behavior });
1529
+ return node;
1232
1530
  }
1233
1531
  getCurrentScrollTop() {
1234
1532
  const scrollHost = this.resolveScrollHost();
@@ -1237,14 +1535,59 @@ export class EBookPlayer {
1237
1535
  }
1238
1536
  return Math.max(0, Math.floor(scrollHost.scrollTop || 0));
1239
1537
  }
1240
- scrollToStoredPosition(scrollTop) {
1538
+ scrollToStoredPosition(scrollTop, behavior = "smooth") {
1241
1539
  const safeTop = Math.max(0, Math.floor(scrollTop || 0));
1242
1540
  const scrollHost = this.resolveScrollHost();
1243
1541
  if (this.isWindowScrollHost(scrollHost)) {
1244
- window.scrollTo({ top: safeTop, behavior: "auto" });
1542
+ window.scrollTo({ top: safeTop, behavior });
1543
+ return;
1544
+ }
1545
+ scrollHost.scrollTo({ top: safeTop, behavior });
1546
+ }
1547
+ scrollToNodeWithTopOffset(node, topOffset, behavior = "smooth") {
1548
+ const safeOffset = Math.max(0, Math.floor(topOffset || 0));
1549
+ const scrollHost = this.resolveScrollHost();
1550
+ if (this.isWindowScrollHost(scrollHost)) {
1551
+ const absoluteTop = node.getBoundingClientRect().top + window.scrollY;
1552
+ const targetTop = Math.max(0, Math.floor(absoluteTop - safeOffset));
1553
+ window.scrollTo({ top: targetTop, behavior });
1245
1554
  return;
1246
1555
  }
1247
- scrollHost.scrollTo({ top: safeTop, behavior: "auto" });
1556
+ const hostRect = scrollHost.getBoundingClientRect();
1557
+ const relativeTop = node.getBoundingClientRect().top - hostRect.top + scrollHost.scrollTop;
1558
+ const targetTop = Math.max(0, Math.floor(relativeTop - safeOffset));
1559
+ scrollHost.scrollTo({ top: targetTop, behavior });
1560
+ }
1561
+ focusAfterJump(options) {
1562
+ const { selector, focusClass = "hyepl-jump-focus", fallbackNode = null, } = options;
1563
+ this.deferFocus(() => {
1564
+ const matched = Array.from(this.content.querySelectorAll(selector));
1565
+ if (matched.length) {
1566
+ this.playFocusAnimation(matched, focusClass);
1567
+ return;
1568
+ }
1569
+ if (fallbackNode) {
1570
+ this.playFocusAnimation([fallbackNode], "hyepl-jump-focus");
1571
+ }
1572
+ });
1573
+ }
1574
+ deferFocus(task) {
1575
+ window.setTimeout(task, JUMP_SCROLL_FOCUS_DELAY_MS);
1576
+ }
1577
+ playFocusAnimation(nodes, className = "hyepl-jump-focus") {
1578
+ const seen = new Set();
1579
+ for (const node of nodes) {
1580
+ if (!node || !this.content.contains(node) || seen.has(node)) {
1581
+ continue;
1582
+ }
1583
+ seen.add(node);
1584
+ node.classList.remove(className);
1585
+ void node.offsetWidth;
1586
+ node.classList.add(className);
1587
+ window.setTimeout(() => {
1588
+ node.classList.remove(className);
1589
+ }, JUMP_FOCUS_ANIMATION_MS + 40);
1590
+ }
1248
1591
  }
1249
1592
  resolveScrollHost() {
1250
1593
  let current = this.container;
@@ -1397,3 +1740,12 @@ function escapeHtml(value) {
1397
1740
  function escapeAttribute(value) {
1398
1741
  return escapeHtml(value);
1399
1742
  }
1743
+ function resolveErrorMessage(error, fallback = "未知错误") {
1744
+ if (error instanceof Error && error.message) {
1745
+ return error.message;
1746
+ }
1747
+ if (typeof error === "string" && error.trim()) {
1748
+ return error.trim();
1749
+ }
1750
+ return fallback;
1751
+ }
@@ -1,5 +1,5 @@
1
1
  import type { EbookDoc } from "../types/ebook";
2
- import type { ReaderAnnotationCreateEvent, ReaderAnnotationDeleteEvent, ReaderAnnotations, ReaderAnnotationsChangeEvent, ReaderAnnotationUpdateEvent, ReaderBookmark, ReaderHighlight, PlayerDataProvider, PlayerEngineOptions, PlayerFeatures, ReaderNote, ReaderProgress } from "../types/player";
2
+ import type { ReaderAnnotationCreateEvent, ReaderAnnotationDeleteEvent, ReaderAnnotations, ReaderAnnotationsChangeEvent, ReaderAnnotationUpdateEvent, ReaderBookmark, ReaderBookmarkPatch, ReaderHighlight, PlayerDataProvider, PlayerEngineOptions, PlayerFeatures, ReaderNote, ReaderProgress } from "../types/player";
3
3
  export interface PlayerState {
4
4
  currentPage: number;
5
5
  zoom: number;
@@ -49,6 +49,7 @@ export declare class PlayerEngine {
49
49
  updateNote(noteId: string, patch: Partial<Omit<ReaderNote, "id" | "bookId" | "createdAt">>, source?: "api" | "user"): Promise<ReaderNote | null>;
50
50
  deleteNote(noteId: string, source?: "api" | "user"): Promise<boolean>;
51
51
  setBookmark(pageId: string, blockId: string, content: string, source?: "api" | "user", scrollTop?: number): Promise<ReaderBookmark>;
52
+ updateBookmark(bookmarkId: string, patch: ReaderBookmarkPatch, source?: "api" | "user"): Promise<ReaderBookmark | null>;
52
53
  deleteBookmark(bookmarkId: string, source?: "api" | "user"): Promise<boolean>;
53
54
  onAnnotationCreate(listener: AnnotationCreateListener): () => void;
54
55
  onAnnotationUpdate(listener: AnnotationUpdateListener): () => void;