bloom-player 2.8.10 → 2.9.0

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.
@@ -0,0 +1,452 @@
1
+ import $ from "jquery";
2
+ import "jquery.nicescroll";
3
+ import { IsRunningOnBloomDesktop } from "./dragActivityRuntime";
4
+
5
+ export const kSelectorForPotentialNiceScrollElements =
6
+ ".bloom-translationGroup:not(.bloom-imageDescription) .bloom-editable.bloom-visibility-code-on, " +
7
+ ".scrollable"; // we added .scrollable for branding cases where the boilerplate text also needs to scroll
8
+
9
+ // Add a "nice" scrollbar to the page if the content overflows.
10
+ export function addScrollbarsToPage(
11
+ bloomPage: Element,
12
+ pointerEventHandler?: (e: PointerEvent) => void,
13
+ ): void {
14
+ // Expected behavior for cover: "on the cover, which is has a very dynamic layout, we just don't do scrollbars"
15
+ if (bloomPage.classList.contains("cover")) {
16
+ return;
17
+ }
18
+ const isRunningOnBloomDesktop = IsRunningOnBloomDesktop(bloomPage);
19
+
20
+ // on a browser so obsolete that it doesn't have IntersectionObserver (e.g., IE or Safari before 12.2),
21
+ // we just won't get scrolling.
22
+ if ("IntersectionObserver" in window) {
23
+ // Attach overlaid scrollbar to all editables except textOverPictures (e.g. comics)
24
+ // Expected behavior for comic bubbles: "we want overflow to show, but not generate scroll bars"
25
+ let scrollBlocks: HTMLElement[] = [];
26
+ let countOfObserversExpectedToReport = 0;
27
+ let countOfObserversThatHaveReported = 0;
28
+ $(bloomPage)
29
+ .find(kSelectorForPotentialNiceScrollElements)
30
+ .each((index, elt) => {
31
+ // Process the blocks that are possibly overflowing.
32
+ // Blocks that are overflowing will be configured to use niceScroll
33
+ // so the user can scroll and see everything. That is costly, because
34
+ // niceScroll leaks event listeners every time it is called. So we don't
35
+ // want to use it any more than we need to. (Is this true?) Also, niceScroll
36
+ // somehow fails to work when our vertical alignment classes are applied;
37
+ // probably something to do with the bloom-editables being display:flex
38
+ // to achieve vertical positioning. We can safely remove those classes
39
+ // if the block is overflowing, because there's no excess white space
40
+ // to distribute.
41
+ // Note: there are complications Bloom desktop handles in determining
42
+ // accurately whether a block is overflowing. We don't handle those here.
43
+ // If it is close enough to overflow to get a scroll bar, it's close
44
+ // enough not to care whether extra white space is at the top, bottom,
45
+ // or split (hence we can safely remove classes used for that).
46
+ // And we'll risk sometimes adding niceScroll when we could (just)
47
+ // have done without it. Using the same code in Bloom Desktop and bloom-player
48
+ // ensures consistent scrollbar behavior between the two.
49
+ const firstChild = elt.firstElementChild;
50
+ const lastChild = elt.lastElementChild;
51
+ if (!firstChild || !lastChild) {
52
+ // no children, can't be overflowing
53
+ return;
54
+ }
55
+
56
+ // We need to know the scale of the page, because nicescroll doesn't handle
57
+ // scaling when it comes to the padding that we add to the top and left of
58
+ // the translationGroup element. See BL-13796.
59
+ let scale = 1;
60
+ // bloom-player approach to setting the scale
61
+ const scaleStyle = document.querySelector(
62
+ "style#scale-style-sheet",
63
+ );
64
+ if (scaleStyle) {
65
+ const match = scaleStyle.innerHTML.match(
66
+ /transform:[a-z0-9, ()]* scale\((\d+(\.\d+)?)\)/,
67
+ );
68
+ if (match) {
69
+ scale = parseFloat(match[1]);
70
+ }
71
+ } else {
72
+ // Bloom Desktop approach to setting the scale
73
+ const scaleContainer = elt.closest(
74
+ "#page-scaling-container",
75
+ ) as HTMLElement;
76
+ if (scaleContainer) {
77
+ const scaleValue = scaleContainer.style.transform;
78
+ if (scaleValue && scaleValue.startsWith("scale(")) {
79
+ scale = parseFloat(
80
+ scaleValue.substring(6, scaleValue.length - 1),
81
+ );
82
+ }
83
+ }
84
+ }
85
+ // nicescroll doesn't provide a way to adjust the height of the scrollbar
86
+ // ("rail"). Adjusting the height of the thumb ("cursor") is counterproductive
87
+ // if the scrollbar height doesn't change, so we don't try to adjust that
88
+ // aspect of the thumb.
89
+ const { topAdjust, leftAdjust, thumbWidth } =
90
+ ComputeNiceScrollOffsets(scale, elt as HTMLElement);
91
+
92
+ // We don't really want continuous observation, but this is an elegant
93
+ // way to find out whether each child is entirely contained within its
94
+ // parent. Unlike computations involving coordinates, we don't have to
95
+ // worry about whether borders, margins, and padding are included in
96
+ // various measurements. We do need to check the first as well as the
97
+ // last child, because if text is aligned bottom, any overflow will be
98
+ // at the top.
99
+ const observer = new IntersectionObserver(
100
+ (entries, ob) => {
101
+ // called more-or-less immediately for each child, but after the
102
+ // loop creates them all.
103
+ entries.forEach((entry) => {
104
+ countOfObserversThatHaveReported++;
105
+ ob.unobserve(entry.target); // don't want to keep getting them, or leak observers
106
+ const isBubble = !!entry.target.closest(
107
+ ".bloom-textOverPicture",
108
+ );
109
+ // In bloom desktop preview, we set width to 200% and then scale down by 50%.
110
+ // This can lead to intersection ratios very slightly less than 1, probably due
111
+ // to pixel rounding of some sort, when in fact the content fits comfortably.
112
+ // For example, in one case we got a boundingClientRect 72.433 high
113
+ // and an intersectionRect 72.416, for a ratio of 0.9998.
114
+ // If a block is 1000 pixels high and really overflowing by 1 pixel, the ratio
115
+ // will be 0.999. I think it's safe to take anything closer to 1 than that as
116
+ // 'not overflowing'.
117
+ let overflowing = entry.intersectionRatio < 0.999;
118
+
119
+ if (overflowing && isBubble) {
120
+ // We want to be less aggressive about putting scroll bars on bubbles.
121
+ // Most of the time, a bubble is very carefully sized to just fit the
122
+ // text. But the intersection observer wants it to fit a certain amount
123
+ // of white space as well. We want a scroll bar if it's overflowing
124
+ // really badly for some reason, but that's much more the exception
125
+ // than the rule, so better a little clipping when the bubble is badly
126
+ // sized than a scroll bar that isn't needed in one that is just right.
127
+ // Example: a bubble which appears to fit perfectly, 3 lines high:
128
+ // its clientHeight is 72; containing bloom-editable's is 59;
129
+ // lineHeight is 24px. IntersectionRatio computes to 59/72,
130
+ // which makes the 'overflow' 13. A ratio of 0.5 as we originally
131
+ // proposed would give us a scroll bar we don't want.
132
+ let maxBubbleOverflowLineFraction = 0.6;
133
+ if (
134
+ entry.target !=
135
+ entry.target.parentElement
136
+ ?.firstElementChild ||
137
+ entry.target !=
138
+ entry.target.parentElement!
139
+ .lastElementChild
140
+ ) {
141
+ // Bubbles are center-aligned vertically. If this is not the only
142
+ // child,the first and last will overflow above and below by about the
143
+ // same amount. So we're only really looking at half the overflow on this para,
144
+ // and should reduce the threshold.
145
+ maxBubbleOverflowLineFraction /= 2;
146
+ }
147
+ const overflow =
148
+ (1 - entry.intersectionRatio) *
149
+ entry.target.clientHeight;
150
+ const lineHeightPx = window.getComputedStyle(
151
+ entry.target,
152
+ ).lineHeight;
153
+ const lineHeight = parseFloat(
154
+ // remove the trailing "px"
155
+ lineHeightPx.substring(
156
+ 0,
157
+ lineHeightPx.length - 2,
158
+ ),
159
+ );
160
+ overflowing =
161
+ overflow >
162
+ lineHeight * maxBubbleOverflowLineFraction;
163
+ }
164
+ if (
165
+ overflowing &&
166
+ scrollBlocks.indexOf(
167
+ entry.target.parentElement!,
168
+ ) < 0
169
+ ) {
170
+ scrollBlocks.push(entry.target.parentElement!);
171
+ // remove classes incompatible with niceScroll
172
+ const group =
173
+ entry.target.parentElement!.parentElement!;
174
+ if (
175
+ group.classList.contains(
176
+ "bloom-vertical-align-center",
177
+ )
178
+ ) {
179
+ group.classList.remove(
180
+ "bloom-vertical-align-center",
181
+ );
182
+ if (isRunningOnBloomDesktop)
183
+ group.classList.add(
184
+ "bloom-vertical-align-center-removed",
185
+ );
186
+ }
187
+ if (
188
+ group.classList.contains(
189
+ "bloom-vertical-align-bottom",
190
+ )
191
+ ) {
192
+ group.classList.remove(
193
+ "bloom-vertical-align-bottom",
194
+ );
195
+ if (isRunningOnBloomDesktop)
196
+ group.classList.add(
197
+ "bloom-vertical-align-bottom-removed",
198
+ );
199
+ }
200
+ if (isBubble) {
201
+ // This is a way of forcing it not to be display-flex, which doesn't
202
+ // work with the nice-scroll-bar library we're using.
203
+ // That library messes with the element style, so it seemed safer
204
+ // not to do that myself.
205
+ entry.target.parentElement!.classList.add(
206
+ "scrolling-bubble",
207
+ );
208
+ }
209
+ }
210
+ if (
211
+ countOfObserversThatHaveReported ===
212
+ countOfObserversExpectedToReport
213
+ ) {
214
+ // configure nicescroll...ideally only once for all of them
215
+ $(scrollBlocks).niceScroll({
216
+ autohidemode: false,
217
+ railoffset: {
218
+ top: -topAdjust,
219
+ left: -leftAdjust,
220
+ },
221
+ cursorwidth: thumbWidth,
222
+ cursorcolor: "#000000",
223
+ cursoropacitymax: 0.1,
224
+ cursorborderradius: thumbWidth, // Make the corner more rounded than the 5px default.
225
+ });
226
+ setupSpecialMouseTrackingForNiceScroll(
227
+ bloomPage,
228
+ pointerEventHandler,
229
+ );
230
+ scrollBlocks = []; // Just in case it's possible to get callbacks before we created them all.
231
+ }
232
+ });
233
+ },
234
+ { root: elt },
235
+ );
236
+ countOfObserversExpectedToReport++;
237
+ observer.observe(firstChild);
238
+ if (firstChild !== lastChild) {
239
+ countOfObserversExpectedToReport++;
240
+ observer.observe(lastChild);
241
+ }
242
+ });
243
+ }
244
+ }
245
+
246
+ // This method is copied from the nicescroll source code, albeit with some renamings and
247
+ // getting rid of jquery as much as possible. This is how nicescroll determines the parent
248
+ // element that should have the inserted scrollbar elements. If nothing is found by this
249
+ // method, nicescroll uses the body element. Since the nicescroll release hasn't been updated
250
+ // since 2017, I feel safe in copying this method here in January 2025.
251
+ function getNiceScrollParent(elt: HTMLElement): HTMLElement | null {
252
+ var parentElt =
253
+ elt && elt.parentNode ? (elt.parentNode as HTMLElement) : null;
254
+ while (
255
+ parentElt &&
256
+ parentElt.nodeType === Node.ELEMENT_NODE &&
257
+ !/^BODY|HTML/.test(parentElt.nodeName)
258
+ ) {
259
+ const computed = window.getComputedStyle(parentElt);
260
+ const position = computed.getPropertyValue("position");
261
+ if (/fixed|absolute/.test(position)) return parentElt;
262
+ const ov =
263
+ computed.getPropertyValue("overflow-y") ||
264
+ computed.getPropertyValue("overflow-x") ||
265
+ computed.getPropertyValue("overflow") ||
266
+ "";
267
+ if (
268
+ /scroll|auto/.test(ov) &&
269
+ parentElt.clientHeight != parentElt.scrollHeight
270
+ )
271
+ return parentElt;
272
+ if (($(parentElt).getNiceScroll() as any).length > 0) return parentElt;
273
+ parentElt = parentElt.parentNode
274
+ ? (parentElt.parentNode as HTMLElement)
275
+ : null;
276
+ }
277
+ return null;
278
+ }
279
+
280
+ // nicescroll doesn't properly scale the padding at the top and left of the
281
+ // scrollable area of the languageGroup divs when the page is scaled. This
282
+ // method computes offset values to correct for this. See BL-13796.
283
+ // nicescroll also doesn't scale at all when nicescroll cannot find a scrollable
284
+ // area containing the element under the scaling div. See BL-14112.
285
+ function ComputeNiceScrollOffsets(
286
+ scale: number,
287
+ elt: HTMLElement,
288
+ ): { topAdjust: number; leftAdjust: number; thumbWidth: string } {
289
+ let topAdjust = 0;
290
+ let leftAdjust = 0;
291
+ let thumbWidth = "12px"; // nicescroll calls the thumb a "cursor", but it's really a thumb
292
+ if (scale !== 1) {
293
+ const translationGroupDiv = elt.parentElement;
294
+ if (
295
+ !translationGroupDiv ||
296
+ !translationGroupDiv.classList.contains("bloom-translationGroup")
297
+ ) {
298
+ // We don't know how to deal with this case, so we'll just return the default values.
299
+ return { topAdjust, leftAdjust, thumbWidth };
300
+ }
301
+ const whereToPutTheScrollbars = getNiceScrollParent(elt);
302
+ if (whereToPutTheScrollbars) {
303
+ // The nicescroll elements are added somewhere in the DOM that is presumably inside
304
+ // the element that sets the scaling.
305
+ const compStyles = window.getComputedStyle(translationGroupDiv);
306
+ const topPadding =
307
+ compStyles.getPropertyValue("padding-top") || "0";
308
+ const leftPadding =
309
+ compStyles.getPropertyValue("padding-left") || "0";
310
+ topAdjust = parseFloat(topPadding) * (scale - 1);
311
+ leftAdjust = parseFloat(leftPadding) * (scale - 1);
312
+ } else {
313
+ // The nicescroll elements are added directly under the body element, which is presumably
314
+ // outside the element that sets the scaling. We need to adjust for the scaling of the
315
+ // scrollbar position and size ourselves. See BL-14112.
316
+ const splitPageComponentInner = translationGroupDiv.parentElement;
317
+ if (!splitPageComponentInner) {
318
+ // This should never happen, but if it does, we'll just return the default values.
319
+ return { topAdjust, leftAdjust, thumbWidth };
320
+ }
321
+ // This seems to apply only to text-only pages which don't have any additional padding
322
+ // to worry about: only the basic page dimensions given by the splitPageComponentInner
323
+ // element.
324
+ thumbWidth = `${12 * scale}px`;
325
+ const top = splitPageComponentInner?.offsetTop;
326
+ const right =
327
+ splitPageComponentInner.offsetLeft +
328
+ splitPageComponentInner.offsetWidth;
329
+ topAdjust = -(top * (scale - 1));
330
+ leftAdjust = -(right * (scale - 1));
331
+ }
332
+ }
333
+ return { topAdjust, leftAdjust, thumbWidth };
334
+ }
335
+
336
+ export function setupSpecialMouseTrackingForNiceScroll(
337
+ bloomPage: Element,
338
+ pointerEventHandler?: (e: PointerEvent) => void,
339
+ ) {
340
+ bloomPage.removeEventListener("pointerdown", listenForPointerDown); // only want one!
341
+ bloomPage.addEventListener("pointerdown", listenForPointerDown);
342
+ if (!pointerEventHandler) {
343
+ return;
344
+ }
345
+ // The purpose of this is to prevent Swiper causing the page to be moved or
346
+ // flicked when the user is trying to scroll on the page. See BL-14079.
347
+ for (const eventName of ["pointermove", "pointerup"]) {
348
+ bloomPage.ownerDocument.body.addEventListener(
349
+ eventName,
350
+ pointerEventHandler,
351
+ {
352
+ capture: true,
353
+ },
354
+ );
355
+ }
356
+ }
357
+
358
+ // nicescroll doesn't properly scale the padding at the top and left of the
359
+ // scrollable area of the languageGroup divs when the page is scaled. This
360
+ // method sets offset values to correct for this. It is called whenever the
361
+ // entire window resizes, which also scales the page before this is called.
362
+ // See BL-13796.
363
+ export function fixNiceScrollOffsets(page: HTMLElement, scale: number) {
364
+ page.querySelectorAll(kSelectorForPotentialNiceScrollElements).forEach(
365
+ (group) => {
366
+ // The type definition is not correct for getNiceScroll; we expect it to return an array.
367
+ const groupNiceScroll = $(group).getNiceScroll() as any;
368
+ if (groupNiceScroll && groupNiceScroll.length > 0) {
369
+ let { topAdjust, leftAdjust, thumbWidth } =
370
+ ComputeNiceScrollOffsets(scale, group as HTMLElement);
371
+ groupNiceScroll[0].opt.railoffset.top = -topAdjust;
372
+ groupNiceScroll[0].opt.railoffset.left = -leftAdjust;
373
+ groupNiceScroll[0].opt.cursorwidth = thumbWidth;
374
+ groupNiceScroll[0].resize();
375
+ }
376
+ },
377
+ );
378
+ }
379
+
380
+ // If the mouse down is in the thumb of a NiceScroll, we don't want to get a click
381
+ // event later even if the mouse up is outside that element. Also, we want the
382
+ // scrolling to follow the mouse movement even if the mouse cursor leaves the thumb
383
+ // before the mouse button is released.
384
+ function listenForPointerDown(ev: PointerEvent) {
385
+ if (
386
+ ev.target instanceof HTMLDivElement &&
387
+ (ev.target as HTMLDivElement).classList.contains("nicescroll-cursors")
388
+ ) {
389
+ (ev.target as HTMLDivElement).setPointerCapture(ev.pointerId);
390
+ if (ev.pointerType === "mouse") {
391
+ // Investigation shows that Swiper uses pointer event handlers and NiceScroll
392
+ // uses mouse event handlers, so stopping the propagation of pointer events
393
+ // doesn't effect the scrolling, but does stop the swiping. See BL-14079.
394
+ // Pointer capture affects mouse events as well as pointer events.
395
+ ev.stopPropagation();
396
+ }
397
+ }
398
+ }
399
+
400
+ export function cleanupNiceScroll() {
401
+ // Doing this cleanup is unfortunate overhead, but niceScrolls stick around too much,
402
+ // including when the page divs they are on are removed because the page is not the
403
+ // current page. This leads to performance issues, including the scrollbar getting darker
404
+ // and darker as the nicescroll elements build up in the HTML. This may also be the source
405
+ // of the event listener leaks that was mentioned in an earlier comment.
406
+ $("div.bloom-page")[0]
407
+ ?.querySelectorAll(kSelectorForPotentialNiceScrollElements)
408
+ .forEach((group) => {
409
+ // The "as" cast is crucial here for this code to work. For some reason,
410
+ // the type returned by getNiceScroll() is not interpreted correctly and
411
+ // the code silently fails to work, with length always being 0.
412
+ // (bloom-player uses "as any" in this context, but "as JQuery" seems to
413
+ // work as well and doesn't trigger an eslint warning.)
414
+ const groupNiceScroll = $(
415
+ group,
416
+ ).getNiceScroll() as unknown as JQuery;
417
+ if (groupNiceScroll && groupNiceScroll.length > 0) {
418
+ groupNiceScroll.remove();
419
+ }
420
+ // Remove classes added to make the niceScroll work, and restore
421
+ // classes that were removed to make the niceScroll work.
422
+ if (group.classList.contains("scrolling-bubble")) {
423
+ group.classList.remove("scrolling-bubble");
424
+ }
425
+ const groupParent = group.parentElement;
426
+ if (!groupParent) return; // this should never happen, but just in case
427
+ if (
428
+ groupParent.classList.contains(
429
+ "bloom-vertical-align-center-removed",
430
+ )
431
+ ) {
432
+ groupParent.classList.remove(
433
+ "bloom-vertical-align-center-removed",
434
+ );
435
+ groupParent.classList.add("bloom-vertical-align-center");
436
+ }
437
+ if (
438
+ groupParent.classList.contains(
439
+ "bloom-vertical-align-bottom-removed",
440
+ )
441
+ ) {
442
+ groupParent.classList.remove(
443
+ "bloom-vertical-align-bottom-removed",
444
+ );
445
+ groupParent.classList.add("bloom-vertical-align-bottom");
446
+ }
447
+ // Remove more debris left by niceScroll. See BL-14052.
448
+ (group as HTMLElement).style.overflow = "";
449
+ (group as HTMLElement).style.outline = "";
450
+ (group as HTMLElement).style.width = "";
451
+ });
452
+ }