Recently Written · git

scan-subread

PDF reader that follows an audiobook with the SubRead suite

git clone https://github.com/equwal/scan-subread

Log | Files | Refs


src/status.ts (5459 bytes)

1 // The status strip under the top bar. It has three parts: the player,
2 // the reading of the pages, and the last event. Only the event part is a
3 // live region, because the other parts change often.
4 //
5 // The strip is one line of a fixed height, so the page under it never
6 // moves. A long text ends with an ellipsis, and its title attribute holds
7 // the full text. A phone cannot hover to read the title, so a tap on the
8 // strip shows the full texts in a panel over the top of the page. While an
9 // event shows, it takes the place of the player text.
10 
11 import { stripTexts, type ActionId, type Message } from './messages';
12 
13 /** Milliseconds that an event message stays before it fades. */
14 export const EVENT_MS = 6000;
15 
16 /** Milliseconds of the fade. The same as the transition of #status in style.css. */
17 const FADE_MS = 600;
18 
19 function el(id: string): HTMLElement {
20   const e = document.getElementById(id);
21   if (!e) throw new Error(`Missing element #${id}`);
22   return e;
23 }
24 
25 const strip = el('strip');
26 const player = el('player-status');
27 const reading = el('pages-status');
28 const event = el('status');
29 const full = el('strip-full');
30 
31 let fadeTimer: ReturnType<typeof setTimeout> | undefined;
32 let fullTimer: ReturnType<typeof setTimeout> | undefined;
33 
34 /**
35  * Writes a message into `target`: the text in its own element, so that the
36  * strip can cut it with an ellipsis, then the link or the button. A click
37  * on the button runs the handler of onAction.
38  */
39 export function fill(target: HTMLElement, m: Message): void {
40   const text = document.createElement('span');
41   text.className = 'text';
42   text.textContent = m.text;
43   target.replaceChildren(text);
44   if (m.link) {
45     const a = document.createElement('a');
46     a.href = m.link.href;
47     a.target = '_blank';
48     a.rel = 'noopener';
49     a.textContent = m.link.text;
50     target.append(' ', a);
51   }
52   if (m.action) {
53     const button = document.createElement('button');
54     button.type = 'button';
55     button.className = 'action';
56     button.dataset.action = m.action.id;
57     button.textContent = m.action.text;
58     target.append(' ', button);
59   }
60 }
61 
62 /** Writes a message into a part of the strip, with the full text in the title attribute. */
63 function fillPart(target: HTMLElement, m: Message): void {
64   fill(target, m);
65   if (m.text) target.title = m.text;
66   else target.removeAttribute('title');
67 }
68 
69 let runAction: ((id: ActionId) => void) | null = null;
70 
71 /** Sets the handler of the buttons that fill makes. */
72 export function onAction(handler: (id: ActionId) => void): void {
73   runAction = handler;
74 }
75 
76 /**
77  * Shows the full texts of the parts of the strip in a panel over the top of
78  * the page, until the next tap or for EVENT_MS. The page does not move.
79  */
80 function showFull(): void {
81   const parts = [...strip.children].filter((p): p is HTMLElement => p instanceof HTMLElement);
82   const texts = stripTexts(
83     parts.map((p) => ({ shown: !p.hidden, title: p.title, text: p.textContent ?? '' })),
84   );
85   if (texts.length === 0) return;
86   full.replaceChildren(
87     ...texts.map((text) => {
88       const line = document.createElement('p');
89       line.textContent = text;
90       return line;
91     }),
92   );
93   full.hidden = false;
94   clearTimeout(fullTimer);
95   fullTimer = setTimeout(hideFull, EVENT_MS);
96 }
97 
98 function hideFull(): void {
99   clearTimeout(fullTimer);
100   full.hidden = true;
101 }
102 
103 // A click on a button that fill made runs its action. The next tap closes
104 // the panel of the full texts. A tap on the strip, not on a link or a
105 // button in it, opens the panel.
106 document.addEventListener('click', (e) => {
107   const target = e.target instanceof Element ? e.target : null;
108   const button = target?.closest('button[data-action]');
109   if (button instanceof HTMLButtonElement && button.dataset.action) {
110     runAction?.(button.dataset.action as ActionId);
111   }
112   if (!full.hidden) hideFull();
113   else if (target && strip.contains(target) && !target.closest('a, button')) showFull();
114 });
115 
116 let lastPlayer = '';
117 
118 /** Shows the state of the player. Null hides this part. */
119 export function showPlayer(m: Message | null): void {
120   // The clock sends a state four times a second. A new link under the
121   // finger can lose a tap, so the part changes only when the text changes.
122   const key = m ? `${m.text}|${m.link?.href ?? ''}|${m.action?.id ?? ''}` : '';
123   if (key === lastPlayer) return;
124   lastPlayer = key;
125   player.hidden = m === null;
126   fillPart(player, m ?? { text: '' });
127 }
128 
129 /** Shows how far the reading of the pages is. Null hides this part. */
130 export function showReading(m: Message | string | null): void {
131   reading.hidden = m === null;
132   fillPart(reading, typeof m === 'string' ? { text: m } : (m ?? { text: '' }));
133 }
134 
135 /**
136  * Shows an event, for example a lookup or an error. It fades after EVENT_MS.
137  * While it shows, it takes the place of the player text (style.css): on a
138  * phone the parts do not fit in one line. An open panel of the full texts
139  * shows the new event too.
140  */
141 export function say(m: Message | string): void {
142   fillPart(event, typeof m === 'string' ? { text: m } : m);
143   event.classList.remove('faded');
144   strip.classList.add('event');
145   if (!full.hidden) showFull();
146   clearTimeout(fadeTimer);
147   fadeTimer = setTimeout(() => {
148     event.classList.add('faded');
149     fadeTimer = setTimeout(() => {
150       event.replaceChildren();
151       event.removeAttribute('title');
152       event.classList.remove('faded');
153       strip.classList.remove('event');
154     }, FADE_MS);
155   }, EVENT_MS);
156 }