@internetarchive/bookreader 5.0.0-57 → 5.0.0-59

Sign up to get free protection for your applications and to get access to all the features.
Files changed (53) 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 +8 -0
  21. package/babel.config.js +5 -2
  22. package/package.json +10 -9
  23. package/src/BookReader/BookModel.js +59 -1
  24. package/src/BookReader/Mode1Up.js +5 -0
  25. package/src/BookReader/Mode1UpLit.js +19 -73
  26. package/src/BookReader/Mode2Up.js +72 -1332
  27. package/src/BookReader/Mode2UpLit.js +774 -0
  28. package/src/BookReader/ModeCoordinateSpace.js +29 -0
  29. package/src/BookReader/ModeSmoothZoom.js +32 -0
  30. package/src/BookReader/options.js +8 -2
  31. package/src/BookReader/utils.js +16 -0
  32. package/src/BookReader.js +24 -217
  33. package/src/css/_BRBookmarks.scss +1 -1
  34. package/src/css/_BRmain.scss +14 -0
  35. package/src/css/_BRpages.scss +113 -41
  36. package/src/plugins/plugin.autoplay.js +1 -6
  37. package/src/plugins/tts/WebTTSEngine.js +2 -2
  38. package/src/plugins/tts/plugin.tts.js +3 -17
  39. package/src/plugins/tts/utils.js +0 -16
  40. package/tests/e2e/helpers/base.js +20 -20
  41. package/tests/e2e/helpers/rightToLeft.js +4 -10
  42. package/tests/e2e/viewmode.test.js +10 -8
  43. package/tests/jest/BookReader/BookModel.test.js +25 -0
  44. package/tests/jest/BookReader/BookReaderPublicFunctions.test.js +28 -11
  45. package/tests/jest/BookReader/Mode1UpLit.test.js +0 -19
  46. package/tests/jest/BookReader/Mode2Up.test.js +55 -225
  47. package/tests/jest/BookReader/Mode2UpLit.test.js +190 -0
  48. package/tests/jest/BookReader/ModeCoordinateSpace.test.js +16 -0
  49. package/tests/jest/BookReader/ModeSmoothZoom.test.js +26 -0
  50. package/tests/jest/BookReader/Navbar/Navbar.test.js +3 -3
  51. package/tests/jest/BookReader/utils.test.js +32 -1
  52. package/tests/jest/plugins/tts/utils.test.js +0 -34
  53. package/tests/jest/setup.js +3 -0
@@ -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
+ }