@internetarchive/bookreader 5.0.0-58 → 5.0.0-59

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.
Files changed (50) hide show
  1. package/BookReader/BookReader.css +110 -39
  2. package/BookReader/BookReader.js +1 -1
  3. package/BookReader/BookReader.js.LICENSE.txt +0 -20
  4. package/BookReader/BookReader.js.map +1 -1
  5. package/BookReader/ia-bookreader-bundle.js +1 -1
  6. package/BookReader/ia-bookreader-bundle.js.map +1 -1
  7. package/BookReader/plugins/plugin.archive_analytics.js +1 -1
  8. package/BookReader/plugins/plugin.archive_analytics.js.map +1 -1
  9. package/BookReader/plugins/plugin.autoplay.js +1 -1
  10. package/BookReader/plugins/plugin.autoplay.js.map +1 -1
  11. package/BookReader/plugins/plugin.resume.js +1 -1
  12. package/BookReader/plugins/plugin.resume.js.map +1 -1
  13. package/BookReader/plugins/plugin.tts.js +1 -1
  14. package/BookReader/plugins/plugin.tts.js.map +1 -1
  15. package/BookReader/plugins/plugin.url.js +1 -1
  16. package/BookReader/plugins/plugin.url.js.map +1 -1
  17. package/BookReaderDemo/BookReaderJSAutoplay.js +4 -1
  18. package/BookReaderDemo/BookReaderJSSimple.js +1 -0
  19. package/BookReaderDemo/IADemoBr.js +1 -2
  20. package/CHANGELOG.md +4 -0
  21. package/package.json +1 -1
  22. package/src/BookReader/BookModel.js +59 -1
  23. package/src/BookReader/Mode1UpLit.js +13 -70
  24. package/src/BookReader/Mode2Up.js +72 -1332
  25. package/src/BookReader/Mode2UpLit.js +774 -0
  26. package/src/BookReader/ModeCoordinateSpace.js +29 -0
  27. package/src/BookReader/ModeSmoothZoom.js +32 -0
  28. package/src/BookReader/options.js +8 -2
  29. package/src/BookReader/utils.js +16 -0
  30. package/src/BookReader.js +24 -217
  31. package/src/css/_BRBookmarks.scss +1 -1
  32. package/src/css/_BRmain.scss +14 -0
  33. package/src/css/_BRpages.scss +113 -41
  34. package/src/plugins/plugin.autoplay.js +1 -6
  35. package/src/plugins/tts/WebTTSEngine.js +2 -2
  36. package/src/plugins/tts/plugin.tts.js +3 -17
  37. package/src/plugins/tts/utils.js +0 -16
  38. package/tests/e2e/helpers/base.js +20 -20
  39. package/tests/e2e/helpers/rightToLeft.js +4 -10
  40. package/tests/e2e/viewmode.test.js +10 -8
  41. package/tests/jest/BookReader/BookModel.test.js +25 -0
  42. package/tests/jest/BookReader/BookReaderPublicFunctions.test.js +28 -11
  43. package/tests/jest/BookReader/Mode1UpLit.test.js +0 -19
  44. package/tests/jest/BookReader/Mode2Up.test.js +55 -225
  45. package/tests/jest/BookReader/Mode2UpLit.test.js +190 -0
  46. package/tests/jest/BookReader/ModeCoordinateSpace.test.js +16 -0
  47. package/tests/jest/BookReader/ModeSmoothZoom.test.js +26 -0
  48. package/tests/jest/BookReader/Navbar/Navbar.test.js +3 -3
  49. package/tests/jest/BookReader/utils.test.js +32 -1
  50. package/tests/jest/plugins/tts/utils.test.js +0 -34
@@ -0,0 +1,774 @@
1
+ // @ts-check
2
+ import { customElement, property, query } from 'lit/decorators.js';
3
+ import {LitElement, html} from 'lit';
4
+ import { styleMap } from 'lit/directives/style-map.js';
5
+ import { ModeSmoothZoom } from './ModeSmoothZoom';
6
+ import { arrChanged, promisifyEvent } from './utils';
7
+ import { HTMLDimensionsCacher } from "./utils/HTMLDimensionsCacher";
8
+ import { PageModel } from './BookModel';
9
+ import { ModeCoordinateSpace } from './ModeCoordinateSpace';
10
+ /** @typedef {import('./BookModel').BookModel} BookModel */
11
+ /** @typedef {import('./BookModel').PageIndex} PageIndex */
12
+ /** @typedef {import('./ModeSmoothZoom').SmoothZoomable} SmoothZoomable */
13
+ /** @typedef {import('./PageContainer').PageContainer} PageContainer */
14
+ /** @typedef {import('../BookReader').default} BookReader */
15
+
16
+ // I _have_ to make this globally public, otherwise it won't let me call
17
+ // its constructor :/
18
+ /** @implements {SmoothZoomable} */
19
+ @customElement('br-mode-2up')
20
+ export class Mode2UpLit extends LitElement {
21
+ /****************************************/
22
+ /************** PROPERTIES **************/
23
+ /****************************************/
24
+
25
+ /** @type {BookReader} */
26
+ br;
27
+
28
+ /************** BOOK-RELATED PROPERTIES **************/
29
+
30
+ /** @type {BookModel} */
31
+ @property({ type: Object })
32
+ book;
33
+
34
+ /************** SCALE-RELATED PROPERTIES **************/
35
+
36
+ /** @type {ModeCoordinateSpace} Manage conversion between coordinates */
37
+ coordSpace = new ModeCoordinateSpace(this);
38
+
39
+ @property({ type: Number })
40
+ scale = 1;
41
+
42
+ initialScale = 1;
43
+
44
+ /** Position (in unit-less, [0, 1] coordinates) in client to scale around */
45
+ @property({ type: Object })
46
+ scaleCenter = { x: 0.5, y: 0.5 };
47
+
48
+ /** @type {import('./options').AutoFitValues} */
49
+ @property({ type: String })
50
+ autoFit = 'auto';
51
+
52
+ /************** VIRTUAL-FLIPPING PROPERTIES **************/
53
+
54
+ /** ms for flip animation */
55
+ flipSpeed = 400;
56
+
57
+ @query('.br-mode-2up__leafs--flipping') $flippingEdges;
58
+
59
+ /** @type {PageModel[]} */
60
+ @property({ type: Array, hasChanged: arrChanged })
61
+ visiblePages = [];
62
+
63
+ /** @type {PageModel | null} */
64
+ get pageLeft() {
65
+ return this.visiblePages.find(p => p.pageSide == 'L');
66
+ }
67
+
68
+ /** @type {PageModel | null} */
69
+ get pageRight() {
70
+ return this.visiblePages.find(p => p.pageSide == 'R');
71
+ }
72
+
73
+ /** @type {PageModel[]} */
74
+ @property({ type: Array })
75
+ renderedPages = [];
76
+
77
+ /** @type {Record<PageIndex, PageContainer>} position in inches */
78
+ pageContainerCache = {};
79
+
80
+ /** @type {{ direction: 'left' | 'right', pagesFlipping: [PageIndex, PageIndex], pagesFlippingCount: number }} */
81
+ activeFlip = null;
82
+
83
+ /** @private cache this value */
84
+ _leftCoverWidth = 0;
85
+
86
+ /************** DOM-RELATED PROPERTIES **************/
87
+
88
+ /** @type {HTMLElement} */
89
+ get $container() { return this; }
90
+
91
+ /** @type {HTMLElement} */
92
+ get $visibleWorld() { return this.$book; }
93
+
94
+ /** @type {HTMLElement} */
95
+ @query('.br-mode-2up__book')
96
+ $book;
97
+
98
+ get positions() {
99
+ return this.computePositions(this.pageLeft, this.pageRight);
100
+ }
101
+
102
+ /** @param {PageModel} page */
103
+ computePageHeight(page) {
104
+ return this.book.getMedianPageSizeInches().height;
105
+ }
106
+
107
+ /** @param {PageModel} page */
108
+ computePageWidth(page) {
109
+ return page.widthInches * this.computePageHeight(page) / page.heightInches;
110
+ }
111
+
112
+ /**
113
+ * @param {PageModel | null} pageLeft
114
+ * @param {PageModel | null} pageRight
115
+ */
116
+ computePositions(pageLeft, pageRight) {
117
+ const computePageWidth = this.computePageWidth.bind(this);
118
+ const numLeafs = this.book.getNumLeafs();
119
+ const movingPagesWidth = this.activeFlip ? Math.ceil(this.activeFlip.pagesFlippingCount / 2) * this.PAGE_THICKNESS_IN : 0;
120
+ const leftPagesCount = this.book.pageProgression == 'lr' ? (pageLeft?.index ?? 0) : (!pageLeft ? 0 : numLeafs - pageLeft.index);
121
+
122
+ // Everything is relative to the gutter
123
+ const gutter = this._leftCoverWidth + leftPagesCount * this.PAGE_THICKNESS_IN;
124
+
125
+ const pageLeftEnd = gutter;
126
+ const pageLeftWidth = !pageLeft ? computePageWidth(pageRight.right) : computePageWidth(pageLeft);
127
+ const pageLeftStart = gutter - pageLeftWidth;
128
+
129
+ const leafEdgesLeftEnd = pageLeftStart; // leafEdgesLeftStart + leafEdgesLeftMainWidth + leafEdgesLeftMovingWidth;
130
+ const leafEdgesLeftMovingWidth = this.activeFlip?.direction != 'left' ? 0 : movingPagesWidth;
131
+ const leafEdgesLeftMainWidth = Math.ceil(leftPagesCount / 2) * this.PAGE_THICKNESS_IN - leafEdgesLeftMovingWidth;
132
+ const leafEdgesLeftFullWidth = leafEdgesLeftMovingWidth + leafEdgesLeftMainWidth;
133
+ const leafEdgesLeftMovingStart = leafEdgesLeftEnd - leafEdgesLeftMovingWidth;
134
+ const leafEdgesLeftStart = leafEdgesLeftMovingStart - leafEdgesLeftMainWidth;
135
+
136
+ const pageRightStart = gutter;
137
+ const pageRightWidth = !pageRight ? 0 : computePageWidth(pageRight);
138
+ const pageRightEnd = pageRightStart + pageRightWidth;
139
+
140
+ const rightPagesCount = this.book.pageProgression == 'lr' ? (!pageRight ? 0 : numLeafs - pageRight.index) : (pageRight?.index ?? 0);
141
+ const leafEdgesRightStart = pageRightEnd;
142
+ const leafEdgesRightMovingWidth = this.activeFlip?.direction != 'right' ? 0 : movingPagesWidth;
143
+ const leafEdgesRightMainStart = leafEdgesRightStart + leafEdgesRightMovingWidth;
144
+ const leafEdgesRightMainWidth = Math.ceil(rightPagesCount / 2) * this.PAGE_THICKNESS_IN - leafEdgesRightMovingWidth;
145
+ const leafEdgesRightEnd = leafEdgesRightStart + leafEdgesRightMainWidth + leafEdgesRightMovingWidth;
146
+ const leafEdgesRightFullWidth = leafEdgesRightMovingWidth + leafEdgesRightMainWidth;
147
+
148
+ const spreadWidth = pageRightEnd - pageLeftStart;
149
+ const bookWidth = leafEdgesRightEnd - leafEdgesLeftStart;
150
+ return {
151
+ leafEdgesLeftStart,
152
+ leafEdgesLeftMainWidth,
153
+ leafEdgesLeftMovingStart,
154
+ leafEdgesLeftMovingWidth,
155
+ leafEdgesLeftEnd,
156
+ leafEdgesLeftFullWidth,
157
+
158
+ pageLeftStart,
159
+ pageLeftWidth,
160
+ pageLeftEnd,
161
+
162
+ gutter,
163
+
164
+ pageRightStart,
165
+ pageRightWidth,
166
+ pageRightEnd,
167
+
168
+ leafEdgesRightStart,
169
+ leafEdgesRightMovingWidth,
170
+ leafEdgesRightMainStart,
171
+ leafEdgesRightMainWidth,
172
+ leafEdgesRightEnd,
173
+ leafEdgesRightFullWidth,
174
+
175
+ spreadWidth,
176
+ bookWidth,
177
+ };
178
+ }
179
+
180
+ /** @type {HTMLDimensionsCacher} Cache things like clientWidth to reduce repaints */
181
+ htmlDimensionsCacher = new HTMLDimensionsCacher(this);
182
+
183
+ smoothZoomer = new ModeSmoothZoom(this);
184
+
185
+ /************** CONSTANT PROPERTIES **************/
186
+
187
+ /** How much to zoom when zoom button pressed */
188
+ ZOOM_FACTOR = 1.1;
189
+
190
+ /** How thick a page is in the real world, as an estimate for the leafs */
191
+ PAGE_THICKNESS_IN = 0.002;
192
+
193
+ /****************************************/
194
+ /************** PUBLIC API **************/
195
+ /****************************************/
196
+
197
+ /************** MAIN PUBLIC METHODS **************/
198
+
199
+ /**
200
+ * @param {PageIndex} index
201
+ * TODO Remove smooth option from everywhere.
202
+ */
203
+ async jumpToIndex(index, { smooth = true } = {}) {
204
+ await this.flipAnimation(index, { animate: smooth });
205
+ }
206
+
207
+ zoomIn() {
208
+ this.scale *= this.ZOOM_FACTOR;
209
+ }
210
+
211
+ zoomOut() {
212
+ this.scale *= 1 / this.ZOOM_FACTOR;
213
+ }
214
+
215
+ /********************************************/
216
+ /************** INTERNAL STUFF **************/
217
+ /********************************************/
218
+
219
+ /************** LIFE CYCLE **************/
220
+
221
+ /**
222
+ * @param {BookModel} book
223
+ * @param {BookReader} br
224
+ */
225
+ constructor(book, br) {
226
+ super();
227
+ this.book = book;
228
+
229
+ /** @type {BookReader} */
230
+ this.br = br;
231
+ }
232
+
233
+ /** @override */
234
+ firstUpdated(changedProps) {
235
+ super.firstUpdated(changedProps);
236
+ this.htmlDimensionsCacher.updateClientSizes();
237
+ this.smoothZoomer.attach();
238
+ }
239
+
240
+ /** @override */
241
+ connectedCallback() {
242
+ super.connectedCallback();
243
+ this.htmlDimensionsCacher.attachResizeListener();
244
+ this.smoothZoomer.attach();
245
+ }
246
+
247
+ /** @override */
248
+ disconnectedCallback() {
249
+ this.htmlDimensionsCacher.detachResizeListener();
250
+ this.smoothZoomer.detach();
251
+ super.disconnectedCallback();
252
+ }
253
+
254
+ /** @override */
255
+ updated(changedProps) {
256
+ // this.X is the new value
257
+ // changedProps.get('X') is the old value
258
+ if (changedProps.has('book')) {
259
+ this._leftCoverWidth = this.computePageWidth(this.book.getPage(this.book.pageProgression == 'lr' ? 0 : -1));
260
+ }
261
+ if (changedProps.has('visiblePages')) {
262
+ this.renderedPages = this.computeRenderedPages();
263
+ this.br.displayedIndices = this.visiblePages.map(p => p.index);
264
+ this.br.updateFirstIndex(this.br.displayedIndices[0]);
265
+ this.br._components.navbar.updateNavIndexThrottled();
266
+ }
267
+ if (changedProps.has('autoFit')) {
268
+ if (this.autoFit != 'none') {
269
+ this.resizeViaAutofit();
270
+ }
271
+ }
272
+ if (changedProps.has('scale')) {
273
+ const oldVal = changedProps.get('scale');
274
+ this.recenter();
275
+ this.smoothZoomer.updateViewportOnZoom(this.scale, oldVal);
276
+ }
277
+ }
278
+
279
+ /************** LIT CONFIGS **************/
280
+
281
+ /** @override */
282
+ createRenderRoot() {
283
+ // Disable shadow DOM; that would require a huge rejiggering of CSS
284
+ return this;
285
+ }
286
+
287
+ /************** RENDERING **************/
288
+
289
+ /** @override */
290
+ render() {
291
+ return html`
292
+ <div class="br-mode-2up__book" @mouseup=${this.handlePageClick}>
293
+ ${this.renderLeafEdges('left')}
294
+ ${this.renderedPages.map(p => this.renderPage(p))}
295
+ ${this.renderLeafEdges('right')}
296
+ </div>`;
297
+ }
298
+
299
+ /** @param {PageModel} page */
300
+ createPageContainer = (page) => {
301
+ return this.pageContainerCache[page.index] || (
302
+ this.pageContainerCache[page.index] = (
303
+ // @ts-ignore I know it's protected, TS! But Mode2Up and BookReader are friends.
304
+ this.br._createPageContainer(page.index)
305
+ )
306
+ );
307
+ }
308
+
309
+ /**
310
+ * @param {PageIndex} startIndex
311
+ */
312
+ initFirstRender(startIndex) {
313
+ const page = this.book.getPage(startIndex);
314
+ const spread = page.spread;
315
+ this.visiblePages = (
316
+ this.book.pageProgression == 'lr' ? [spread.left, spread.right] : [spread.right, spread.left]
317
+ ).filter(p => p);
318
+ this.htmlDimensionsCacher.updateClientSizes();
319
+ this.resizeViaAutofit(page);
320
+ this.initialScale = this.scale;
321
+ }
322
+
323
+ /** @param {PageModel} page */
324
+ renderPage = (page) => {
325
+ const wToR = this.coordSpace.worldUnitsToRenderedPixels;
326
+ const wToV = this.coordSpace.worldUnitsToVisiblePixels;
327
+
328
+ const width = wToR(this.computePageWidth(page));
329
+ const height = wToR(this.computePageHeight(page));
330
+ const isVisible = this.visiblePages.map(p => p.index).includes(page.index);
331
+ const positions = this.computePositions(page.spread.left, page.spread.right);
332
+
333
+ const pageContainerEl = this.createPageContainer(page)
334
+ .update({
335
+ dimensions: {
336
+ width,
337
+ height,
338
+ top: 0,
339
+ left: wToR(page.pageSide == 'L' ? positions.pageLeftStart : positions.pageLeftEnd),
340
+ },
341
+ reduce: page.width / wToV(this.computePageWidth(page)),
342
+ }).$container[0];
343
+
344
+ pageContainerEl.classList.toggle('BRpage-visible', isVisible);
345
+ return pageContainerEl;
346
+ }
347
+
348
+ /**
349
+ * @param {'left' | 'right'} side
350
+ * Renders the current leaf edges, as well as any "moving" leaf edges,
351
+ * i.e. leaf edges that are currently being flipped. Uses a custom element
352
+ * to render br-leaf-edges.
353
+ **/
354
+ renderLeafEdges = (side) => {
355
+ if (!this.visiblePages.length) return html``;
356
+ const fullWidthIn = side == 'left' ? this.positions.leafEdgesLeftFullWidth : this.positions.leafEdgesRightFullWidth;
357
+ if (!fullWidthIn) return html``;
358
+
359
+ const wToR = this.coordSpace.worldUnitsToRenderedPixels;
360
+ const height = wToR(this.computePageHeight(this.visiblePages[0]));
361
+ const hasMovingPages = this.activeFlip?.direction == side;
362
+
363
+ const leftmostPage = this.book.getPage(this.book.pageProgression == 'lr' ? 0 : this.book.getNumLeafs() - 1);
364
+ const rightmostPage = this.book.getPage(this.book.pageProgression == 'lr' ? this.book.getNumLeafs() - 1 : 0);
365
+ const numPagesFlipping = hasMovingPages ? this.activeFlip.pagesFlippingCount : 0;
366
+ const range = side == 'left' ?
367
+ [leftmostPage.index, this.pageLeft.goLeft(numPagesFlipping).index] :
368
+ [this.pageRight.goRight(numPagesFlipping).index, rightmostPage.index];
369
+
370
+ const mainEdgesStyle = {
371
+ width: `${wToR(side == 'left' ? this.positions.leafEdgesLeftMainWidth : this.positions.leafEdgesRightMainWidth)}px`,
372
+ height: `${height}px`,
373
+ left: `${wToR(side == 'left' ? this.positions.leafEdgesLeftStart : this.positions.leafEdgesRightMainStart)}px`,
374
+ };
375
+ const mainEdges = html`
376
+ <br-leaf-edges
377
+ leftIndex=${range[0]}
378
+ rightIndex=${range[1]}
379
+ .book=${this.book}
380
+ .pageClickHandler=${(index) => this.br.jumpToIndex(index)}
381
+ side=${side}
382
+ class="br-mode-2up__leafs br-mode-2up__leafs--${side}"
383
+ style=${styleMap(mainEdgesStyle)}
384
+ ></br-leaf-edges>
385
+ `;
386
+
387
+ if (hasMovingPages) {
388
+ const width = wToR(side == 'left' ? this.positions.leafEdgesLeftMovingWidth : this.positions.leafEdgesRightMovingWidth);
389
+ const style = {
390
+ width: `${width}px`,
391
+ height: `${height}px`,
392
+ left: `${wToR(side == 'left' ? this.positions.leafEdgesLeftMovingStart : this.positions.leafEdgesRightStart)}px`,
393
+ pointerEvents: 'none',
394
+ transformOrigin: `${wToR(side == 'left' ? this.positions.pageLeftWidth : -this.positions.pageRightWidth) + width / 2}px 0`,
395
+ };
396
+
397
+ const movingEdges = html`
398
+ <br-leaf-edges
399
+ leftIndex=${this.activeFlip.pagesFlipping[0]}
400
+ rightIndex=${this.activeFlip.pagesFlipping[1]}
401
+ .book=${this.book}
402
+ side=${side}
403
+ class="br-mode-2up__leafs br-mode-2up__leafs--${side} br-mode-2up__leafs--flipping"
404
+ style=${styleMap(style)}
405
+ ></br-leaf-edges>
406
+ `;
407
+
408
+ return side == 'left' ? html`${mainEdges}${movingEdges}` : html`${movingEdges}${mainEdges}`;
409
+ } else {
410
+ return mainEdges;
411
+ }
412
+ }
413
+
414
+ resizeViaAutofit(page = this.visiblePages[0]) {
415
+ this.scale = this.computeScale(page, this.autoFit);
416
+ }
417
+
418
+ recenter(page = this.visiblePages[0]) {
419
+ const translate = this.computeTranslate(page, this.scale);
420
+ this.$book.style.transform = `translateX(${translate.x}px) translateY(${translate.y}px) scale(${this.scale})`;
421
+ }
422
+
423
+ /**
424
+ * @returns {PageModel[]}
425
+ */
426
+ computeRenderedPages() {
427
+ // Also render 2 pages before/after
428
+ // @ts-ignore TS doesn't understand the filtering out of null values
429
+ return [
430
+ this.visiblePages[0]?.prev?.prev,
431
+ this.visiblePages[0]?.prev,
432
+ ...this.visiblePages,
433
+ this.visiblePages[this.visiblePages.length - 1]?.next,
434
+ this.visiblePages[this.visiblePages.length - 1]?.next?.next,
435
+ ]
436
+ .filter(p => p)
437
+ // Never render more than 10 pages! Usually means something is wrong
438
+ .slice(0, 10);
439
+ }
440
+
441
+ /**
442
+ * @param {PageModel} page
443
+ * @param {import('./options').AutoFitValues} autoFit
444
+ */
445
+ computeScale(page, autoFit) {
446
+ if (!page) return 1;
447
+ const spread = page.spread;
448
+ // Default to real size if it fits, otherwise default to full height
449
+ const bookWidth = this.computePositions(spread.left, spread.right).bookWidth;
450
+ const bookHeight = this.computePageHeight(spread.left || spread.right);
451
+ const BOOK_PADDING_PX = 10;
452
+ const curScale = this.scale;
453
+ this.scale = 1; // Need this temporarily
454
+ const widthScale = this.coordSpace.renderedPixelsToWorldUnits(this.htmlDimensionsCacher.clientWidth - 2 * BOOK_PADDING_PX) / bookWidth;
455
+ const heightScale = this.coordSpace.renderedPixelsToWorldUnits(this.htmlDimensionsCacher.clientHeight - 2 * BOOK_PADDING_PX) / bookHeight;
456
+ this.scale = curScale;
457
+ const realScale = 1;
458
+
459
+ let scale = realScale;
460
+ if (autoFit == 'width') {
461
+ scale = Math.min(widthScale, 1);
462
+ } else if (autoFit == 'height') {
463
+ scale = Math.min(heightScale, 1);
464
+ } else if (autoFit == 'auto') {
465
+ scale = Math.min(widthScale, heightScale, 1);
466
+ } else if (autoFit == 'none') {
467
+ scale = this.scale;
468
+ } else {
469
+ // Should be impossible
470
+ throw new Error(`Invalid autoFit value: ${autoFit}`);
471
+ }
472
+
473
+ return scale;
474
+ }
475
+
476
+ /**
477
+ * @param {PageModel} page
478
+ * @param {number} scale
479
+ * @returns {{x: number, y: number}}
480
+ */
481
+ computeTranslate(page, scale = this.scale) {
482
+ if (!page) return { x: 0, y: 0 };
483
+ const spread = page.spread;
484
+ // Default to real size if it fits, otherwise default to full height
485
+ const positions = this.computePositions(spread.left, spread.right);
486
+ const bookWidth = positions.bookWidth;
487
+ const bookHeight = this.computePageHeight(spread.left || spread.right);
488
+ const visibleBookWidth = this.coordSpace.worldUnitsToRenderedPixels(bookWidth) * scale;
489
+ const visibleBookHeight = this.coordSpace.worldUnitsToRenderedPixels(bookHeight) * scale;
490
+ const leftOffset = this.coordSpace.worldUnitsToRenderedPixels(-positions.leafEdgesLeftStart) * scale;
491
+ const translateX = (this.htmlDimensionsCacher.clientWidth - visibleBookWidth) / 2 + leftOffset;
492
+ const translateY = (this.htmlDimensionsCacher.clientHeight - visibleBookHeight) / 2;
493
+ return { x: Math.max(leftOffset, translateX), y: Math.max(0, translateY) };
494
+ }
495
+
496
+ /************** VIRTUAL FLIPPING LOGIC **************/
497
+
498
+ /**
499
+ * @param {'left' | 'right' | 'next' | 'prev' | PageIndex | PageModel | {left: PageModel | null, right: PageModel | null}} nextSpread
500
+ */
501
+ async flipAnimation(nextSpread, { animate = true } = {}) {
502
+ const curSpread = (this.pageLeft || this.pageRight).spread;
503
+ nextSpread = this.parseNextSpread(nextSpread);
504
+ if (this.activeFlip || !nextSpread) return;
505
+
506
+ const progression = this.book.pageProgression;
507
+ const curLeftIndex = curSpread.left?.index ?? (progression == 'lr' ? -1 : this.book.getNumLeafs());
508
+ const nextLeftIndex = nextSpread.left?.index ?? (progression == 'lr' ? -1 : this.book.getNumLeafs());
509
+ if (curLeftIndex == nextLeftIndex) return;
510
+
511
+ const renderedIndices = this.renderedPages.map(p => p.index);
512
+ /** @type {PageContainer[]} */
513
+ const nextPageContainers = [];
514
+ for (const page of [nextSpread.left, nextSpread.right]) {
515
+ if (!page) continue;
516
+ nextPageContainers.push(this.createPageContainer(page));
517
+ if (!renderedIndices.includes(page.index)) {
518
+ this.renderedPages.push(page);
519
+ }
520
+ }
521
+
522
+ const curTranslate = this.computeTranslate(curSpread.left || curSpread.right, this.scale);
523
+ const idealNextTranslate = this.computeTranslate(nextSpread.left || nextSpread.right, this.scale);
524
+ const translateDiff = Math.sqrt((idealNextTranslate.x - curTranslate.x) ** 2 + (idealNextTranslate.y - curTranslate.y) ** 2);
525
+ let nextTranslate = `translate(${idealNextTranslate.x}px, ${idealNextTranslate.y}px)`;
526
+ if (translateDiff < 50) {
527
+ const activeTranslate = this.$book.style.transform.match(/translate\([^)]+\)/)?.[0];
528
+ if (activeTranslate) {
529
+ nextTranslate = activeTranslate;
530
+ }
531
+ }
532
+ const newTransform = `${nextTranslate} scale(${this.scale})`;
533
+
534
+ if (animate && 'animate' in Element.prototype) {
535
+ // This table is used to determine the direction of the flip animation:
536
+ // | < | >
537
+ // lr | L | R
538
+ // rl | R | L
539
+ const direction = progression == 'lr' ? (nextLeftIndex > curLeftIndex ? 'right' : 'left') : (nextLeftIndex > curLeftIndex ? 'left' : 'right');
540
+
541
+ this.activeFlip = {
542
+ direction,
543
+ pagesFlipping: [curLeftIndex, nextLeftIndex],
544
+ pagesFlippingCount: Math.abs(nextLeftIndex - curLeftIndex),
545
+ };
546
+
547
+ this.classList.add(`br-mode-2up--flipping-${direction}`);
548
+ this.classList.add(`BRpageFlipping`);
549
+
550
+ // Wait for lit update cycle to finish
551
+ this.requestUpdate();
552
+ await this.updateComplete;
553
+
554
+ this.visiblePages
555
+ .map(p => this.pageContainerCache[p.index].$container)
556
+ .forEach($c => $c.addClass('BRpage-exiting'));
557
+
558
+ nextPageContainers.forEach(c => c.$container.addClass('BRpage-visible BRpage-entering'));
559
+
560
+ /** @type {KeyframeAnimationOptions} */
561
+ const animationStyle = {
562
+ duration: this.flipSpeed + this.activeFlip.pagesFlippingCount,
563
+ easing: 'ease-in',
564
+ fill: 'none',
565
+ };
566
+
567
+ const bookCenteringAnimation = this.$book.animate([
568
+ { transform: newTransform },
569
+ ], animationStyle);
570
+
571
+ const edgeTranslationAnimation = this.$flippingEdges.animate([
572
+ { transform: `rotateY(0deg)` },
573
+ {
574
+ transform: direction == 'left' ? `rotateY(-180deg)` : `rotateY(180deg)`,
575
+ },
576
+ ], animationStyle);
577
+
578
+ const exitingPageAnimation = direction == 'left' ?
579
+ this.querySelector('.BRpage-exiting[data-side=L]').animate([
580
+ { transform: `rotateY(0deg)` },
581
+ { transform: `rotateY(180deg)` },
582
+ ], animationStyle) : this.querySelector('.BRpage-exiting[data-side=R]').animate([
583
+ { transform: `rotateY(0deg)` },
584
+ { transform: `rotateY(-180deg)` },
585
+ ], animationStyle);
586
+
587
+ const enteringPageAnimation = direction == 'left' ?
588
+ this.querySelector('.BRpage-entering[data-side=R]').animate([
589
+ { transform: `rotateY(-180deg)` },
590
+ { transform: `rotateY(0deg)` },
591
+ ], animationStyle) : this.querySelector('.BRpage-entering[data-side=L]').animate([
592
+ { transform: `rotateY(180deg)` },
593
+ { transform: `rotateY(0deg)` },
594
+ ], animationStyle);
595
+
596
+ bookCenteringAnimation.play();
597
+ edgeTranslationAnimation.play();
598
+ exitingPageAnimation.play();
599
+ enteringPageAnimation.play();
600
+
601
+ await Promise.race([
602
+ promisifyEvent(bookCenteringAnimation, 'finish'),
603
+ promisifyEvent(edgeTranslationAnimation, 'finish'),
604
+ promisifyEvent(exitingPageAnimation, 'finish'),
605
+ promisifyEvent(enteringPageAnimation, 'finish'),
606
+ ]);
607
+
608
+ this.classList.remove(`br-mode-2up--flipping-${direction}`);
609
+ this.classList.remove(`BRpageFlipping`);
610
+
611
+ this.visiblePages
612
+ .map(p => this.pageContainerCache[p.index].$container)
613
+ .forEach($c => $c.removeClass('BRpage-exiting BRpage-visible'));
614
+ nextPageContainers.forEach(c => c.$container.removeClass('BRpage-entering'));
615
+ this.activeFlip = null;
616
+ }
617
+
618
+ this.$book.style.transform = newTransform;
619
+ this.visiblePages = (
620
+ progression == 'lr' ? [nextSpread.left, nextSpread.right] : [nextSpread.right, nextSpread.left]
621
+ ).filter(x => x);
622
+ }
623
+
624
+ /**
625
+ * @param {'left' | 'right' | 'next' | 'prev' | PageIndex | PageModel | {left: PageModel | null, right: PageModel | null}} nextSpread
626
+ * @returns {{left: PageModel | null, right: PageModel | null}}
627
+ */
628
+ parseNextSpread(nextSpread) {
629
+ if (nextSpread == 'next') {
630
+ nextSpread = this.book.pageProgression == 'lr' ? 'right' : 'left';
631
+ } else if (nextSpread == 'prev') {
632
+ nextSpread = this.book.pageProgression == 'lr' ? 'left' : 'right';
633
+ }
634
+
635
+ const curSpread = (this.pageLeft || this.pageRight).spread;
636
+
637
+ if (nextSpread == 'left') {
638
+ nextSpread = curSpread.left?.findLeft({ combineConsecutiveUnviewables: true })?.spread;
639
+ } else if (nextSpread == 'right') {
640
+ nextSpread = curSpread.right?.findRight({ combineConsecutiveUnviewables: true })?.spread;
641
+ }
642
+
643
+ if (typeof(nextSpread) == 'number') {
644
+ nextSpread = this.book.getPage(nextSpread).spread;
645
+ }
646
+
647
+ if (nextSpread instanceof PageModel) {
648
+ nextSpread = nextSpread.spread;
649
+ }
650
+
651
+ return nextSpread;
652
+ }
653
+
654
+ /************** INPUT HANDLERS **************/
655
+
656
+ /**
657
+ * @param {MouseEvent} ev
658
+ */
659
+ handlePageClick = (ev) => {
660
+ // right click
661
+ if (ev.which == 3 && this.br.protected) {
662
+ return false;
663
+ }
664
+
665
+ if (ev.which != 1) return;
666
+
667
+ const $page = $(ev.target).closest('.BRpagecontainer');
668
+ if (!$page.length) return;
669
+ if ($page.data('side') == 'L') {
670
+ this.flipAnimation('left');
671
+ } else if ($page.data('side') == 'R') {
672
+ this.flipAnimation('right');
673
+ }
674
+ }
675
+ }
676
+
677
+ @customElement('br-leaf-edges')
678
+ export class LeafEdges extends LitElement {
679
+ @property({ type: Number }) leftIndex = 0;
680
+ @property({ type: Number }) rightIndex = 0;
681
+ /** @type {'left' | 'right'} */
682
+ @property({ type: String }) side = 'left';
683
+
684
+ /** @type {BookModel} */
685
+ @property({attribute: false})
686
+ book = null;
687
+
688
+ /** @type {(index: PageIndex) => void} */
689
+ @property({attribute: false, type: Function})
690
+ pageClickHandler = null;
691
+
692
+ @query('.br-leaf-edges__bar') $hoverBar;
693
+ @query('.br-leaf-edges__label') $hoverLabel;
694
+
695
+ get pageWidthPercent() {
696
+ return 100 * 1 / (this.rightIndex - this.leftIndex + 1);
697
+ }
698
+
699
+ render() {
700
+ return html`
701
+ <div
702
+ class="br-leaf-edges__bar"
703
+ style="${styleMap({width: `${this.pageWidthPercent}%`})}"
704
+ ></div>
705
+ <div class="br-leaf-edges__label">Page</div>`;
706
+ }
707
+
708
+ connectedCallback() {
709
+ super.connectedCallback();
710
+ this.addEventListener('mouseenter', this.onMouseEnter);
711
+ this.addEventListener('mouseleave', this.onMouseLeave);
712
+ this.addEventListener('click', this.onClick);
713
+ }
714
+ disconnectedCallback() {
715
+ super.disconnectedCallback();
716
+ this.addEventListener('mouseenter', this.onMouseEnter);
717
+ this.removeEventListener('mousemove', this.onMouseMove);
718
+ this.removeEventListener('mouseleave', this.onMouseLeave);
719
+ }
720
+
721
+ /** @override */
722
+ createRenderRoot() {
723
+ // Disable shadow DOM; that would require a huge rejiggering of CSS
724
+ return this;
725
+ }
726
+
727
+ /**
728
+ * @param {MouseEvent} e
729
+ */
730
+ onMouseEnter = (e) => {
731
+ this.addEventListener('mousemove', this.onMouseMove);
732
+ this.$hoverBar.style.display = 'block';
733
+ this.$hoverLabel.style.display = 'block';
734
+ }
735
+
736
+ /**
737
+ * @param {MouseEvent} e
738
+ */
739
+ onMouseMove = (e) => {
740
+ this.$hoverBar.style.left = `${e.offsetX}px`;
741
+ if (this.side == 'right') {
742
+ this.$hoverLabel.style.left = `${e.offsetX}px`;
743
+ } else {
744
+ this.$hoverLabel.style.right = `${this.offsetWidth - e.offsetX}px`;
745
+ }
746
+ this.$hoverLabel.style.top = `${e.offsetY}px`;
747
+ const index = this.mouseEventToPageIndex(e);
748
+ this.$hoverLabel.textContent = this.book.getPageName(index);
749
+ }
750
+
751
+ /**
752
+ * @param {MouseEvent} e
753
+ */
754
+ onMouseLeave = (e) => {
755
+ this.removeEventListener('mousemove', this.onMouseMove);
756
+ this.$hoverBar.style.display = 'none';
757
+ this.$hoverLabel.style.display = 'none';
758
+ }
759
+
760
+ /**
761
+ * @param {MouseEvent} e
762
+ */
763
+ onClick = (e) => {
764
+ this.pageClickHandler(this.mouseEventToPageIndex(e));
765
+ }
766
+
767
+ /**
768
+ * @param {MouseEvent} e
769
+ * @returns {PageIndex}
770
+ */
771
+ mouseEventToPageIndex(e) {
772
+ return Math.floor(this.leftIndex + (e.offsetX / this.offsetWidth) * (this.rightIndex - this.leftIndex + 1));
773
+ }
774
+ }