fuzzyworld

explodorama / files / src / ui / holdRing.ts

holdRing.ts

Blame: the commit that last changed each line · file · history

92cc4ee3 2026-11-02 Pavel Safronov1/**
2 * The ring that fills while a face button is held (Q59, padHold.ts): one element on the page,
3 * moved over the face button's picture wherever that is (action bar, dialog, banner). Shape and
4 * look are CSS (style.css .hold-ring); the progress is its `--p` (0..1).
5 *
6 * Q72: the ring belongs to the picture that began it (its originator). It follows that picture
7 * every animation frame, not only when the pad is polled, and is gone the same frame the picture
8 * is: removed, hidden, zero-sized, or inside a dialog that is closing or closed.
9 */
10export interface HoldRing {
11 /** The ring around the visible picture of face button `face` inside the element `scope` (a selector), filled `progress`. Nothing visible there: no ring. */
12 show(face: number, progress: number, scope: string): void
13 hide(): void
14}
15
16/** What is known about a ring's originator in one frame. */
17export interface OriginState {
18 /** Still in the page. */
19 connected: boolean
20 /** Drawn: it and every ancestor are displayed, with some size. */
21 rendered: boolean
22 /** Inside a dialog that is closing (its pop-out animation) or no longer open. */
23 inClosingDialog: boolean
24}
25
26/** Whether the ring may show: only while the picture that began it is there to be seen. */
27export function ringVisible(o: OriginState): boolean {
28 return o.connected && o.rendered && !o.inClosingDialog
29}
30
31/** `ringVisible`'s input, read from the page. */
32function originState(el: HTMLElement): OriginState {
33 const dialog = el.closest('dialog')
34 const r = el.getBoundingClientRect()
35 return {
36 connected: el.isConnected,
37 // `checkVisibility` is Chrome 105+ / Safari 17.4+; older browsers (older iPhones) fall back to the computed style.
38 rendered: r.width > 0 && r.height > 0 && (el.checkVisibility ? el.checkVisibility({ checkVisibilityCSS: true }) : getComputedStyle(el).visibility !== 'hidden'),
39 inClosingDialog: !!dialog && (!dialog.open || dialog.classList.contains('closing')),
40 }
41}
42
43export function createHoldRing(): HoldRing {
44 const ring = document.createElement('div')
45 ring.className = 'hold-ring floating'
46 ring.hidden = true
47 ring.setAttribute('aria-hidden', 'true')
48 document.body.append(ring)
49 /** The picture the ring is on; null when the ring is down. */
50 let origin: HTMLElement | null = null
51 let frame = 0
52
53 /** Puts the ring on its originator, or takes it down if that is gone. Every frame while it is up. */
54 function follow(): void {
55 frame = 0
56 if (!origin) return
57 if (!ringVisible(originState(origin))) return hide()
58 const r = origin.getBoundingClientRect()
59 const size = Math.max(r.width, r.height) * 1.5
60 ring.style.left = `${r.left + r.width / 2 - size / 2}px`
61 ring.style.top = `${r.top + r.height / 2 - size / 2}px`
62 ring.style.width = ring.style.height = `${size}px`
63 frame = requestAnimationFrame(follow)
64 }
65
66 function hide(): void {
67 origin = null
68 ring.hidden = true
69 if (frame) cancelAnimationFrame(frame)
70 frame = 0
71 }
72
73 return {
74 show(face, progress, scope) {
75 let target: HTMLElement | null = null
76 for (const img of document.querySelectorAll<HTMLElement>(`${scope} .pad-glyph[data-face="${face}"]`)) {
77 if (ringVisible(originState(img))) {
78 target = img
79 break
80 }
81 }
82 if (!target) return hide()
83 origin = target
84 // A modal dialog is in the top layer, above anything on the page: the ring goes inside it then.
85 const parent = target.closest('dialog') ?? document.body
86 if (ring.parentElement !== parent) parent.append(ring)
87 ring.style.setProperty('--p', String(progress))
88 ring.classList.toggle('full', progress >= 1)
89 ring.hidden = false
90 if (frame) cancelAnimationFrame(frame)
91 follow()
92 },
93 hide,
94 }
95}