-
Notifications
You must be signed in to change notification settings - Fork 45
/
puck.ts
1030 lines (891 loc) · 33.8 KB
/
puck.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { SVG_NS } from '../utils/dom-utils';
import { MarginBox } from '../utils/geometry';
import { getThemeClass } from '../utils/themes';
import { isIOS } from '../utils/ua-utils';
import {
getOrCreateEmptyContainer,
removeContentContainer,
} from './content-container';
import { getIframeOrigin } from './iframes';
import type { SafeAreaProvider } from './safe-area-provider';
import puckStyles from '../../css/puck.css';
interface ViewportDimensions {
viewportWidth: number;
viewportHeight: number;
}
export interface PuckRenderOptions {
icon: 'default' | 'sky';
theme: string;
}
export function isPuckMouseEvent(
mouseEvent: MouseEvent
): mouseEvent is PuckMouseEvent {
return !!(mouseEvent as PuckMouseEvent).fromPuck;
}
export interface PuckMouseEvent extends MouseEvent {
fromPuck: true;
}
type ClickState =
| {
kind: 'idle';
}
| {
kind: 'firstpointerdown';
// This is the timeout we use to detect if it's a drag or not
timeout: number;
}
| {
kind: 'dragging';
}
| {
kind: 'firstclick';
// This is the timeout we use to detect if it's a double-click or not
timeout: number;
}
| {
kind: 'secondpointerdown';
// This is the same timeout as we start when we enter the firstclick state
timeout: number;
};
interface ClickStateBase<T extends string> {
kind: T;
}
interface ClickStateWithTimeout<T extends string> extends ClickStateBase<T> {
timeout: number;
}
function clickStateHasTimeout<T extends ClickState['kind']>(
clickState: ClickStateBase<T>
): clickState is ClickStateWithTimeout<T> {
return typeof (clickState as ClickStateWithTimeout<T>).timeout === 'number';
}
function clearClickTimeout(clickState: ClickState) {
if (clickStateHasTimeout(clickState)) {
clearTimeout(clickState.timeout);
}
}
// - 'disabled': Does not listen for any events (so can't be moved
// nor tapped).
// - 'inactive': Listens for events (so can be moved and tapped),
// but does not look up words.
// - 'active': Listens for events (so can be moved and tapped), and
// furthermore looks up words.
export type PuckEnabledState = 'disabled' | 'inactive' | 'active';
export class LookupPuck {
public static id = 'tenten-ja-puck';
private puck: HTMLDivElement | undefined;
private enabledState: PuckEnabledState = 'disabled';
private puckX: number;
private puckY: number;
private earthWidth: number;
private earthHeight: number;
private earthScaleFactorWhenDragging: number;
private moonWidth: number;
private moonHeight: number;
// The translateY value to apply to the moon when it is orbiting above the
// earth. Expressed as an absolute (positive) value.
private targetAbsoluteOffsetYAbove: number;
// The translateY value to apply to the moon when it is orbiting below the
// earth. Expressed as an absolute (positive) value.
private targetAbsoluteOffsetYBelow: number;
// The translate (X and Y) values applied to the moon whilst it is being
// dragged. They are measured relative to the midpoint of the moon (which is
// also the midpoint of the earth).
private targetOffset: { x: number; y: number } = { x: 0, y: 0 };
private targetOrientation: 'above' | 'below' = 'above';
private cachedViewportDimensions: ViewportDimensions | null = null;
// We need to detect if the browser has a buggy position:fixed behavior
// (as is currently the case for Safari
// https://bugs.webkit.org/show_bug.cgi?id=207089)
// so we can adjust the way we position the puck.
//
// This probably should _also_ apply to the way we position the safe area
// but we haven't looked into that case just yet.
//
// undefined means we haven't been able to detect whether or not the bug is
// present yet.
private hasBuggyPositionFixed: boolean | undefined;
constructor(
private safeAreaProvider: SafeAreaProvider,
private onLookupDisabled: () => void
) {}
// @see SafeAreaConsumerDelegate
onSafeAreaUpdated(): void {
this.cachedViewportDimensions = null;
this.setPositionWithinSafeArea(this.puckX, this.puckY);
}
private setPosition({
x,
y,
safeAreaLeft,
safeAreaRight,
}: {
x: number;
y: number;
safeAreaLeft: number;
safeAreaRight: number;
}) {
this.puckX = x;
this.puckY = y;
// Update the puck position (that is, the earth)
if (this.puck) {
this.puck.style.transform = `translate(${this.puckX}px, ${this.puckY}px)`;
}
// Calculate the corresponding target point (that is, the moon)
// First determine the actual range of motion of the moon, taking into
// account any safe area on either side of the screen.
const { viewportWidth } = this.getViewportDimensions(document);
const safeAreaWidth = viewportWidth - safeAreaLeft - safeAreaRight;
// Now work out where the moon is within that range such that it is
//
// * 0 when the the left side of the earth is touching the left safe area
// inset, and
// * 1 when the right side of the earth is touching the right safe area
// inset.
const clamp = (num: number, min: number, max: number) =>
Math.min(Math.max(num, min), max);
const horizontalPortion = clamp(
(this.puckX - safeAreaLeft) / (safeAreaWidth - this.earthWidth),
0,
1
);
// Then we calculate the horizontal offset. We need to ensure that we
// produce enough displacement that we can reach to the other edge of the
// safe area in either direction.
// The range is the amount the moon rotates either side of the moon, in this
// case 45 degrees in either direction.
const range = Math.PI / 2;
// We need to determine the radius of the offset.
//
// Typically we set this to 10 pixels greater than the radius of the earth
// itself.
const radiusOfEarth = this.earthWidth / 2;
const preferredRadius = radiusOfEarth + 10;
// However, we may need to extend that to reach the other side of the safe
// area.
const safeAreaExtent = Math.max(safeAreaLeft, safeAreaRight);
const requiredReach = safeAreaExtent + radiusOfEarth;
const requiredRadius = requiredReach / Math.sin(range / 2);
// Choose whichever is larger
const offsetRadius = Math.max(preferredRadius, requiredRadius);
// Now finally we can calculate the horizontal offset.
const angle = horizontalPortion * range - range / 2;
const offsetX = Math.sin(angle) * offsetRadius;
// For the vertical offset, we don't actually extend the moon out by the
// same radius but instead try to keep a fixed vertical offset since that
// makes scanning horizontally easier and allows us to tweak that offset to
// make room for the user's thumb.
const offsetYOrientationFactor =
this.targetOrientation === 'above' ? -1 : 1;
const offsetY =
(this.targetOrientation === 'above'
? this.targetAbsoluteOffsetYAbove
: this.targetAbsoluteOffsetYBelow) * offsetYOrientationFactor;
// At rest, make the target land on the surface of the puck.
const restOffsetX = Math.sin(angle) * radiusOfEarth;
const restOffsetY =
Math.cos(angle) * radiusOfEarth * offsetYOrientationFactor;
this.targetOffset = { x: offsetX, y: offsetY };
// Update target position in style
if (this.puck) {
this.puck.style.setProperty('--target-x-offset', `${offsetX}px`);
this.puck.style.setProperty('--target-y-offset', `${offsetY}px`);
this.puck.style.setProperty('--rest-x-offset', `${restOffsetX}px`);
this.puck.style.setProperty('--rest-y-offset', `${restOffsetY}px`);
}
}
// Returns the total clearance to allow arround the target offset for the
// puck.
public getPuckClearance(): MarginBox {
const moonVerticalClearance = this.moonHeight / 2;
const earthVerticalClearance =
Math.abs(this.targetOffset.y) +
(this.earthScaleFactorWhenDragging * this.earthHeight) / 2;
return {
top:
this.targetOrientation === 'above'
? moonVerticalClearance
: earthVerticalClearance,
bottom:
this.targetOrientation === 'above'
? earthVerticalClearance
: moonVerticalClearance,
left:
(this.earthScaleFactorWhenDragging * this.earthWidth) / 2 +
this.targetOffset.x,
right:
(this.earthScaleFactorWhenDragging * this.earthWidth) / 2 -
this.targetOffset.x,
};
}
public getTargetOrientation(): 'above' | 'below' {
return this.targetOrientation;
}
private getViewportDimensions(document: Document): ViewportDimensions {
if (this.cachedViewportDimensions) {
return this.cachedViewportDimensions;
}
// We'd ideally use document.documentElement.clientWidth and
// document.documentElement.clientHeight for both viewport measurements, but
// iOS 15 Safari doesn't behave suitably for that.
//
// iOS 15 Safari:
//
// - seems to measure its safe area insets from the area defined by
// document.defaultView.innerHeight and .innerWidth.
//
// - decreases both document.defaultView.innerHeight and the
// safe-area-inset-bottom in compact mode, and vice versa in non-compact
// mode.
//
// @see https://github.com/shirakaba/10ten-ja-reader/pull/3#issuecomment-875127566
//
// Another curiousity, if you load a page initially zoomed-in using pinch
// zoom (e.g. by refreshing it after zooming in), the innerHeight will
// initially report the zoomed-in viewport height (i.e. the same value as
// window.visualViewport.height). However, if you zoom all the way out and
// back in again, it will give you the layout viewport. If you zoom
// partially out and back in, you get something in between.
this.cachedViewportDimensions = {
viewportWidth: document.documentElement.clientWidth,
viewportHeight:
document.defaultView?.innerHeight ??
document.documentElement.clientHeight,
};
return this.cachedViewportDimensions;
}
private setPositionWithinSafeArea(x: number, y: number) {
if (!this.puck) {
return;
}
const {
top: safeAreaTop,
right: safeAreaRight,
bottom: safeAreaBottom,
left: safeAreaLeft,
} = this.safeAreaProvider.getSafeArea() || {
top: 0,
right: 0,
bottom: 0,
left: 0,
};
const { viewportWidth, viewportHeight } =
this.getViewportDimensions(document);
const minX = safeAreaLeft;
const maxX = viewportWidth - safeAreaRight - this.earthWidth;
const minY = safeAreaTop;
const maxY = viewportHeight - safeAreaBottom - this.earthHeight;
let clampedX = Math.min(Math.max(minX, x), maxX);
let clampedY = Math.min(Math.max(minY, y), maxY);
// When we initialize the puck, we put it in the bottom-right corner, but on
// iOS 15 Safari, if it's flush up against the right edge of the screen then
// when you try to drag it away, you end up dragging in the next tab.
//
// To avoid that we detect the initial position coordinates and add a few
// pixels margin.
if (x === Number.MAX_SAFE_INTEGER && y === Number.MAX_SAFE_INTEGER) {
clampedX -= 15;
clampedY -= 15;
}
this.setPosition({
x: clampedX,
y: clampedY,
safeAreaLeft,
safeAreaRight,
});
}
readonly onWindowPointerMove = (event: PointerEvent) => {
if (
!this.puck ||
!this.earthWidth ||
!this.earthHeight ||
this.enabledState === 'disabled' ||
// If we're not being pressed or dragged, ignore
!(
this.clickState.kind === 'dragging' ||
this.clickState.kind === 'firstpointerdown' ||
this.clickState.kind === 'secondpointerdown'
)
) {
return;
}
event.preventDefault();
let { clientX, clientY } = event;
// Factor in any viewport offset needed to make up for Safari iOS's buggy
// implementation of position:fixed.
let viewportOffsetLeft = 0;
let viewportOffsetTop = 0;
if (this.hasBuggyPositionFixed) {
viewportOffsetLeft = window.visualViewport?.offsetLeft ?? 0;
viewportOffsetTop = window.visualViewport?.offsetTop ?? 0;
}
clientX += viewportOffsetLeft;
clientY += viewportOffsetTop;
// Translate the midpoint of the earth to the position of the pointer event.
// This updates the moon offset
this.setPositionWithinSafeArea(
clientX - this.earthWidth / 2,
clientY - this.earthHeight / 2
);
if (this.enabledState !== 'active') {
return;
}
// Before applying the transformations to the earth and the moon, they
// both share the same midpoint.
// Work out the midpoint of the moon post-transformations. This is where
// we'll fire the mousemove event to trigger a lookup.
//
// We drop any zoom offsets here since both elementFromPoint and the mouse
// event handlers we pass these coordinates to will expect an unadjusted
// value.
const targetX =
this.puckX +
this.earthWidth / 2 +
this.targetOffset.x -
viewportOffsetLeft;
const targetY =
this.puckY +
this.earthHeight / 2 +
this.targetOffset.y -
viewportOffsetTop;
// Make sure the target is an actual element since the mousemove handler
// expects that.
const target = document.elementFromPoint(targetX, targetY);
if (!target) {
return;
}
// When the target is an iframe, simply firing a 'mousemove' event at it
// does not have the desired effect of prompting a lookup at the target
// location within the iframe.
//
// Instead, we send a 'puckMoved' message to the iframe. Our injected
// content script ensures that the iframe has a listener in place to handle
// this message. Upon receiving this message, the iframe will fire a
// 'mousemove' event at the indicated location, ultimately resulting in a
// lookup at the target point.
//
// Note that this is the one and only case where we use postMessage, the
// reasons for which are described here:
//
// https://github.com/birchill/10ten-ja-reader/issues/747#issuecomment-918774588
//
// For any other cross-frame messaging we should very very strongly prefer
// passing messages via the background page.
if (target.tagName === 'IFRAME') {
const iframeElement = target as HTMLIFrameElement;
const contentWindow = iframeElement.contentWindow;
if (!contentWindow) {
return;
}
// Adjust the target position by the offset of the iframe itself within
// the viewport.
const originPoint = getIframeOrigin(iframeElement);
if (!originPoint) {
return;
}
const { x, y } = originPoint;
contentWindow.postMessage(
{
type: '10ten(ja):puckMoved',
clientX: targetX - x,
clientY: targetY - y,
},
'*'
);
return;
}
const mouseEvent = new MouseEvent('mousemove', {
// Make sure the event bubbles up to the listener on the window
bubbles: true,
clientX: targetX,
clientY: targetY,
});
(mouseEvent as PuckMouseEvent).fromPuck = true;
target.dispatchEvent(mouseEvent);
};
private readonly checkForBuggyPositionFixed = () => {
// Check if we've already run this check
if (typeof this.hasBuggyPositionFixed !== 'undefined') {
return;
}
// Check we have the visual viewport API available.
//
// If not, it's hard to detect the browser bug (since we don't know if we're
// scaled or not) and it's hard to work around it too without flushing style
// on every pointer event so we just act as if there's no bug.
//
// (Normally this function won't be called in the first place if we don't
// have the visual viewport API since we can't register for viewport resize
// events, but we manually call this function initially after rendering so
// we can still arrive here even without the API.)
if (
typeof window.visualViewport === 'undefined' ||
window.visualViewport === null
) {
this.hasBuggyPositionFixed = false;
return;
}
// Check that there is a suitable viewport scale applied so that we could
// potentially detect the bug
if (
Math.abs(window.visualViewport.scale - 1) < 0.01 ||
(Math.abs(window.visualViewport.offsetLeft) <= 1 &&
Math.abs(window.visualViewport.offsetTop) <= 1)
) {
return;
}
// Check the puck is actually being rendered
if (!this.puck) {
return;
}
// Clear the transform on the puck and check if its resting position is
// actually equal to the offset of the visual viewport.
//
// When that's the case we've got iOS's buggy position:fixed that makes the
// element not actually fixed.
//
// https://bugs.webkit.org/show_bug.cgi?id=207089
//
// Furthermore, because the offsets match we know we can work around it
// by factoring the viewport offset into our calculations.
const previousTransform = this.puck.style.transform || 'none';
this.puck.style.transform = 'none';
const bbox = this.puck.getBoundingClientRect();
this.hasBuggyPositionFixed =
Math.abs(bbox.left + window.visualViewport.offsetLeft) < 1 &&
Math.abs(bbox.top + window.visualViewport.offsetTop) < 1;
this.puck.style.transform = previousTransform;
// Don't listen for any more viewport resize events
window.visualViewport.removeEventListener(
'resize',
this.checkForBuggyPositionFixed
);
};
private clickState: ClickState = { kind: 'idle' };
private static readonly clickHysteresis = 300;
private readonly onPuckPointerDown = (event: PointerEvent) => {
if (this.enabledState === 'disabled' || !this.puck) {
return;
}
// Ignore right-clicks
if (event.button) {
return;
}
// NOTE: Some of the code in this function is duplicated in onPuckMouseDown
// so please make sure to keep these two functions in sync.
if (this.clickState.kind === 'idle') {
// If no transition to 'pointerup' occurs during the click hysteresis
// period, then we transition to 'dragging'. This avoids onPuckClick()
// being fired every time the puck gets parked.
this.clickState = {
kind: 'firstpointerdown',
timeout: window.setTimeout(() => {
if (this.clickState.kind === 'firstpointerdown') {
this.clickState = { kind: 'dragging' };
}
}, LookupPuck.clickHysteresis),
};
} else if (this.clickState.kind === 'firstclick') {
// Carry across the timeout from 'firstclick', as we still want to
// transition back to 'idle' if no 'pointerdown' event came within
// the hysteresis period of the preceding 'firstclick' state.
this.clickState = {
...this.clickState,
kind: 'secondpointerdown',
};
}
event.preventDefault();
event.stopPropagation();
this.puck.classList.add('dragging');
this.puck.setPointerCapture(event.pointerId);
// We need to register in the capture phase because Bibi reader (which
// apparently is based on Epub.js) registers a pointermove handler on the
// window in the capture phase and calls `stopPropagation()` on the events
// so if we don't register in the capture phase, we'll never see the events.
window.addEventListener('pointermove', this.onWindowPointerMove, {
capture: true,
});
window.addEventListener('pointerup', this.stopDraggingPuck);
window.addEventListener('pointercancel', this.stopDraggingPuck);
};
// See notes where we register the following two functions (onPuckMouseDown
// and onPuckMouseUp) for why they are needed. The summary is that they are
// only here to work around iOS swallowing pointerevents during the _second_
// tap of a double-tap gesture.
//
// As a result these event listeners are _only_ interested in when we are
// detecting the second tap of a double-tap gesture.
//
// When the pointer events are _not_ swallowed, because we call preventDefault
// on the pointerdown / pointerup events, we these functions should never be
// called.
private readonly onPuckMouseDown = (event: MouseEvent) => {
if (this.enabledState === 'disabled' || !this.puck) {
return;
}
// Ignore right-clicks
if (event.button) {
return;
}
// We only care about detecting the start of a second tap
if (this.clickState.kind !== 'firstclick') {
return;
}
// Following are the important bits of onPuckPointerDown.
//
// Eventually we should find a way to share this code better with that
// function.
this.clickState = {
...this.clickState,
kind: 'secondpointerdown',
};
event.preventDefault();
// See note in onPointerDown for why we need to register in the capture
// phase.
window.addEventListener('pointermove', this.onWindowPointerMove, {
capture: true,
});
window.addEventListener('pointerup', this.stopDraggingPuck);
window.addEventListener('pointercancel', this.stopDraggingPuck);
};
private readonly onPuckMouseUp = (event: MouseEvent) => {
if (this.enabledState === 'disabled' || !this.puck) {
return;
}
// Ignore right-clicks
if (event.button) {
return;
}
// We only care about detecting the end of the second tap in a double-tap
// gesture.
if (this.clickState.kind !== 'secondpointerdown') {
return;
}
event.preventDefault();
event.stopPropagation();
this.stopDraggingPuck();
this.onPuckDoubleClick();
};
private readonly onPuckSingleClick = () => {
this.setEnabledState(
this.enabledState === 'active' ? 'inactive' : 'active'
);
};
private readonly onPuckDoubleClick = () => {
this.targetOrientation =
this.targetOrientation === 'above' ? 'below' : 'above';
this.setPositionWithinSafeArea(this.puckX, this.puckY);
};
// May be called manually (without an event), or upon 'pointerup' or
// 'pointercancel'.
private readonly stopDraggingPuck = (event?: PointerEvent) => {
// Ignore right-clicks
if (event?.button) {
return;
}
if (this.puck) {
this.puck.classList.remove('dragging');
this.setPositionWithinSafeArea(this.puckX, this.puckY);
}
window.removeEventListener('pointermove', this.onWindowPointerMove, {
capture: true,
});
window.removeEventListener('pointerup', this.stopDraggingPuck);
window.removeEventListener('pointercancel', this.stopDraggingPuck);
if (!event || event.type === 'pointercancel') {
clearClickTimeout(this.clickState);
this.clickState = { kind: 'idle' };
return;
}
// Prevent any double-taps turning into a zoom
event.preventDefault();
event.stopPropagation();
if (this.clickState.kind === 'firstpointerdown') {
// Prevent 'firstpointerdown' transitioning to 'dragging' state.
clearClickTimeout(this.clickState);
// Wait for the hysteresis period to expire before calling
// this.onPuckSingleClick() (to rule out a double-click).
this.clickState = {
kind: 'firstclick',
timeout: window.setTimeout(() => {
if (this.clickState.kind === 'firstclick') {
this.clickState = { kind: 'idle' };
this.onPuckSingleClick();
} else if (this.clickState.kind === 'secondpointerdown') {
this.clickState = { kind: 'dragging' };
}
}, LookupPuck.clickHysteresis),
};
} else if (this.clickState.kind === 'secondpointerdown') {
clearClickTimeout(this.clickState);
this.clickState = { kind: 'idle' };
this.onPuckDoubleClick();
} else if (this.clickState.kind === 'dragging') {
this.clickState = { kind: 'idle' };
}
};
private readonly noOpEventHandler = () => {};
render({ icon, theme }: PuckRenderOptions): void {
// Set up shadow tree
const container = getOrCreateEmptyContainer({
id: LookupPuck.id,
styles: puckStyles.toString(),
});
// Create puck elem
this.puck = document.createElement('div');
this.puck.classList.add('puck');
const earth = document.createElement('div');
earth.classList.add('earth');
this.puck.append(earth);
// Brand the earth
const logoSvg = this.renderIcon(icon);
logoSvg.classList.add('logo');
earth.append(logoSvg);
const moon = document.createElement('div');
moon.classList.add('moon');
this.puck.append(moon);
container.shadowRoot!.append(this.puck);
// Set theme styles
this.puck.classList.add(getThemeClass(theme));
// Calculate the earth size (which is equal to the puck's overall size)
if (!this.earthWidth || !this.earthHeight) {
const { width, height } = earth.getBoundingClientRect();
this.earthWidth = width;
this.earthHeight = height;
}
// Calculate the moon size
if (!this.moonWidth || !this.moonHeight) {
const { width, height } = moon.getBoundingClientRect();
this.moonWidth = width;
this.moonHeight = height;
}
if (typeof this.earthScaleFactorWhenDragging === 'undefined') {
this.earthScaleFactorWhenDragging =
parseFloat(
getComputedStyle(earth).getPropertyValue(
'--scale-factor-when-dragging'
)
) || 0;
}
if (
typeof this.targetAbsoluteOffsetYAbove === 'undefined' ||
typeof this.targetAbsoluteOffsetYBelow === 'undefined'
) {
const minimumMoonOffsetY =
parseFloat(
getComputedStyle(moon).getPropertyValue('--minimum-moon-offset-y')
) || 0;
// Depending on whether the moon is above or below the earth, some extra
// altitude needs to be added to the orbit so that the thumb doesn't cover
// it.
const extraAltitudeToClearAboveThumb =
parseFloat(
getComputedStyle(moon).getPropertyValue(
'--extra-altitude-to-clear-above-thumb'
)
) || 0;
const extraAltitudeToClearBelowThumb =
parseFloat(
getComputedStyle(moon).getPropertyValue(
'--extra-altitude-to-clear-above-thumb'
)
) || 0;
// By adding this extra clearance, we avoid the iOS 15 Safari full-size
// URL bar springing back into place when dragging the puck too far into
// the bottom of the viewport. Hopefully this covers the worst-case
// scenario.
// @see https://github.com/shirakaba/10ten-ja-reader/pull/5#issuecomment-877794905
const extraAltitudeToClearIos15SafariSafeAreaActivationZone =
parseFloat(
getComputedStyle(moon).getPropertyValue(
'--extra-altitude-to-clear-ios-15-safari-safe-area-activation-zone'
)
) || 0;
this.targetAbsoluteOffsetYAbove =
minimumMoonOffsetY + extraAltitudeToClearAboveThumb;
this.targetAbsoluteOffsetYBelow =
minimumMoonOffsetY +
extraAltitudeToClearBelowThumb +
extraAltitudeToClearIos15SafariSafeAreaActivationZone;
}
// Place in the bottom-right of the safe area
this.setPositionWithinSafeArea(
Number.MAX_SAFE_INTEGER,
Number.MAX_SAFE_INTEGER
);
// Add event listeners
//
// Note: This currently never happens. We always render before enabling.
if (this.enabledState !== 'disabled') {
this.puck.addEventListener('pointerdown', this.onPuckPointerDown);
this.puck.addEventListener('mousedown', this.onPuckMouseDown);
this.puck.addEventListener('mouseup', this.onPuckMouseUp);
}
// Start trying to detect a buggy position:fixed implementation.
window.visualViewport?.addEventListener(
'resize',
this.checkForBuggyPositionFixed
);
// If the viewport has already been scaled, we might be able to detect it
// right away (and avoid mis-positioning the puck before the viewport is
// next resized).
this.checkForBuggyPositionFixed();
}
private renderIcon(icon: 'default' | 'sky'): SVGSVGElement {
return icon === 'default' ? this.renderDefaultIcon() : this.renderSkyIcon();
}
private renderDefaultIcon(): SVGSVGElement {
const icon = document.createElementNS(SVG_NS, 'svg');
icon.setAttribute('viewBox', '0 0 20 20');
const dot1 = document.createElementNS(SVG_NS, 'circle');
dot1.setAttribute('cx', '11.5');
dot1.setAttribute('cy', '10');
dot1.setAttribute('r', '1.5');
icon.append(dot1);
const dot2 = document.createElementNS(SVG_NS, 'circle');
dot2.setAttribute('cx', '18.5');
dot2.setAttribute('cy', '15.5');
dot2.setAttribute('r', '1.5');
icon.append(dot2);
const path = document.createElementNS(SVG_NS, 'path');
path.setAttribute(
'd',
'M4.9 7.1c-.1-.5-.2-.9-.5-1.3-.2-.4-.5-.8-.8-1.1-.2-.3-.5-.5-.8-.7C2 3.3 1 3 0 3v3c1.2 0 1.9.7 2 1.9v9.2h3V8.2c0-.4 0-.8-.1-1.1zM11.5 3c-2.8 0-5 2.3-5 5.1v3.7c0 2.8 2.2 5.1 5 5.1s5-2.3 5-5.1V8.1c0-2.8-2.2-5.1-5-5.1zm2.3 5.1v3.7c0 .3-.1.6-.2.9-.4.8-1.2 1.4-2.1 1.4s-1.7-.6-2.1-1.4c-.1-.3-.2-.6-.2-.9V8.1c0-.3.1-.6.2-.9.4-.8 1.2-1.4 2.1-1.4s1.7.6 2.1 1.4c.1.3.2.6.2.9z'
);
icon.append(path);
return icon;
}
private renderSkyIcon(): SVGSVGElement {
const icon = document.createElementNS(SVG_NS, 'svg');
icon.setAttribute('viewBox', '0 0 20 20');
const dot1 = document.createElementNS(SVG_NS, 'circle');
dot1.setAttribute('cx', '18.5');
dot1.setAttribute('cy', '15.5');
dot1.setAttribute('r', '1.5');
icon.append(dot1);
const dot2 = document.createElementNS(SVG_NS, 'circle');
dot2.setAttribute('cx', '1.5');
dot2.setAttribute('cy', '4.5');
dot2.setAttribute('r', '1.5');
icon.append(dot2);
const path = document.createElementNS(SVG_NS, 'path');
path.setAttribute(
'd',
'M3.4 3.5c.1.3.2.6.2 1s-.1.7-.2 1h4.1V8H3c-.5 0-1 .5-1 1s.5 1 1 1h4.3c-.3.9-.7 1.6-1.5 2.4-1 1-2.3 1.8-3.8 2.3-.6.2-.9.9-.7 1.5.3.5.9.8 1.4.6 2.9-1.1 5-2.9 6-5.2 1 2.3 3.1 4.1 6 5.2.5.2 1.2-.1 1.4-.6.3-.6 0-1.3-.7-1.5a9.7 9.7 0 0 1-3.8-2.3c-.8-.8-1.2-1.5-1.5-2.4h4.4c.5 0 1-.5 1-1s-.4-1-1-1H10V5.5h5.4c.5 0 1-.5 1-1s-.4-1-1-1h-12z'
);
icon.append(path);
return icon;
}
setTheme(theme: string) {
if (!this.puck) {
return;
}
for (const className of this.puck.classList.values()) {
if (className.startsWith('theme-')) {
this.puck.classList.remove(className);
}
}
this.puck.classList.add(getThemeClass(theme));
}
setIcon(icon: 'default' | 'sky') {
if (!this.puck) {
return;
}
const logo = this.puck.querySelector('.logo');
const logoParent = logo?.parentElement;
if (!logo || !logoParent) {
return;
}
const classes = logo.getAttribute('class') || '';
logo.remove();
const newLogo = this.renderIcon(icon);
newLogo.setAttribute('class', classes);
logoParent.append(newLogo);
}
unmount(): void {
removePuck();
window.visualViewport?.removeEventListener(
'resize',
this.checkForBuggyPositionFixed
);
this.setEnabledState('disabled');
this.puck = undefined;
}
getEnabledState(): PuckEnabledState {
return this.enabledState;
}
setEnabledState(enabledState: PuckEnabledState): void {
const previousState = this.enabledState;
this.enabledState = enabledState;
if (enabledState === 'disabled') {
this.safeAreaProvider.delegate = null;
if (this.puck) {
this.stopDraggingPuck();
this.puck.removeEventListener('pointerdown', this.onPuckPointerDown);
this.puck.removeEventListener('mousedown', this.onPuckMouseDown);
this.puck.removeEventListener('mouseup', this.onPuckMouseUp);
}
window.removeEventListener('pointerup', this.noOpEventHandler);
clearClickTimeout(this.clickState);
this.clickState = { kind: 'idle' };
return;
}
// Avoid redoing any of this setup (that's common between both 'active'
// and 'inactive').
if (previousState === 'disabled') {
this.safeAreaProvider.delegate = this;
if (this.puck) {
this.puck.addEventListener('pointerdown', this.onPuckPointerDown);
// The following event handlers are needed to cover the case where iOS
// Safari sometimes seems to eat the second tap in a double-tap gesture.
//
// We've tried everything to avoid this (touch-action: none,
// -webkit-user-select: none, etc. etc.) but it just sometimes does it.
//
// Furthermore, when debugging, after about ~1hr or so it will somtimes
// _stop_ eating these events, leading you to believe you've fixed it
// only for it to start eating them again a few minutes later.
//
// However, in this case it sill dispatches _mouse_ events so we listen
// to them and trigger the necessary state transitions when needed.
//
// Note that the mere _presence_ of the mousedown handler is also needed
// to prevent double-tap being interpreted as a zoon.
this.puck.addEventListener('mousedown', this.onPuckMouseDown);
this.puck.addEventListener('mouseup', this.onPuckMouseUp);
}
// Needed to stop iOS Safari from stealing pointer events after we finish
// scrolling.
window.addEventListener('pointerup', this.noOpEventHandler);
}
if (this.puck) {
this.puck.classList.toggle(
'lookup-inactive',
this.enabledState === 'inactive'
);
}