pw-repl 0.9.0 → 0.11.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.
package/lib/tabs.js CHANGED
@@ -42,8 +42,9 @@ function markStartTab(p) {
42
42
 
43
43
  // A tab of the REPL's own: closing it can go back to the tab before. It opens
44
44
  // in the background, so it does not take the front of the window from the
45
- // person using the browser; Playwright's newPage would bring it to the front.
45
+ // user using the browser; Playwright's newPage would bring it to the front.
46
46
  const OPEN_TIMEOUT = 10000;
47
+ const FRONT_READ_TIMEOUT = 500;
47
48
 
48
49
  // True while tab new opens a tab: watch on --next-tab waits for one someone
49
50
  // else opens, not one a client of the REPL opens for itself.
@@ -74,7 +75,7 @@ async function openOwnTab() {
74
75
  if (!opened) opened = await ctx.newPage();
75
76
  tabState(opened).opened = true;
76
77
  tabState(opened).owner = state.client;
77
- keepDrawing(opened);
78
+ await keepDrawing(opened);
78
79
  return opened;
79
80
  }
80
81
 
@@ -104,11 +105,24 @@ async function pageForTarget(ctx, targetId) {
104
105
  }
105
106
 
106
107
  // Every tab, URL first, with * on the selected one. The numbers are what tab <index> uses.
108
+ // Whether a tab is the one in front in its window, as the tab itself says: 'in front', 'in front,
109
+ // focused' (its window has the keyboard, and not in the address bar), or null. Not for a tab a client
110
+ // uses, which is told it is in front so that it draws (lib/cdp.js); nor for one that does not answer
111
+ // at once (a dialog open, a frozen tab). Each window has one in front, an incognito one too.
112
+ async function inFront(p) {
113
+ if (tabState(p).focusEmulated) return null;
114
+ const read = p.evaluate(() => [document.visibilityState, document.hasFocus()]).catch(() => null);
115
+ const seen = await Promise.race([read, new Promise(resolve => setTimeout(resolve, FRONT_READ_TIMEOUT, null))]);
116
+ if (!seen || seen[0] !== 'visible') return null;
117
+ return seen[1] ? 'in front, focused' : 'in front';
118
+ }
119
+
107
120
  async function listTabs() {
108
121
  const all = state.browser.contexts().flatMap(c => c.pages());
109
122
  if (state.page && !all.includes(state.page)) state.page = null;
110
123
  state.tabListing = all.slice();
111
124
  if (!all.length) { out.log('No tabs. Use tab new.'); return; }
125
+ const fronts = await Promise.all(all.map(inFront));
112
126
  for (const [i, p] of all.entries()) {
113
127
  let title;
114
128
  try { title = await p.title(); } catch { title = '[title unavailable]'; }
@@ -116,7 +130,7 @@ async function listTabs() {
116
130
  // URL first on its own line: it is what distinguishes otherwise
117
131
  // identically titled tabs, and titles wrap less badly when indented.
118
132
  if (i) out.log('');
119
- const notes = ['start' in tabState(p) && 'the start URL', tabState(p).owner && `opened by ${tabState(p).owner}`].filter(Boolean);
133
+ const notes = [fronts[i], 'start' in tabState(p) && 'the start URL', tabState(p).owner && `opened by ${tabState(p).owner}`].filter(Boolean);
120
134
  out.log(`${marker} [${i}] ${p.url()}${notes.length ? ` (${notes.join(', ')})` : ''}`);
121
135
  out.log(` ${JSON.stringify(title)}`);
122
136
  }
@@ -142,11 +156,13 @@ const commands = {
142
156
  // Matching on the URL, not an index, so a stale listing cannot point at someone else's tab.
143
157
  // Remembers where the REPL was, so closing a tab can go back there, but
144
158
  // only to a tab this REPL opened: the previous one may be someone else's.
145
- const select = p => {
159
+ // Its focus is on before anything acts in it: a click in a tab that is not yet told it is focused
160
+ // can leave it drawing a frame a second (lib/cdp.js).
161
+ const select = async p => {
146
162
  if (p !== state.page) state.previousPage = state.page;
147
163
  state.page = p;
148
164
  ensureDialogHandler(p);
149
- keepDrawing(p);
165
+ await keepDrawing(p);
150
166
  };
151
167
  const byUrl = part => {
152
168
  const matches = all.filter(p => p.url().includes(part));
@@ -157,7 +173,7 @@ const commands = {
157
173
  if (/^\d+$/.test(subcommand)) {
158
174
  if (parts.length) throw new Error(usage);
159
175
  const target = state.tabListing[Number(subcommand)];
160
- if (target && all.includes(target)) { select(target); return commands.info(); }
176
+ if (target && all.includes(target)) { await select(target); return commands.info(); }
161
177
  // Not read as part of a URL instead: that could land in someone else's tab without a word.
162
178
  if (target) throw new Error(`Tab [${subcommand}] has closed since the latest listing. Run tab to list them again.`);
163
179
  // A number that is not a tab in the listing may be part of a URL (a port).
@@ -169,7 +185,7 @@ const commands = {
169
185
  }
170
186
  }
171
187
  if (subcommand === 'new') {
172
- select(await openTab());
188
+ await select(await openTab());
173
189
  const url = parts.join(' ');
174
190
  if (url) {
175
191
  try { await commands.goto(url); }
@@ -196,7 +212,7 @@ const commands = {
196
212
  if (recording) out.log(await endedByTabClose(recording));
197
213
  return listTabs();
198
214
  }
199
- select(byUrl(trimmed));
215
+ await select(byUrl(trimmed));
200
216
  return commands.info();
201
217
  },
202
218
 
package/lib/toast.js ADDED
@@ -0,0 +1,166 @@
1
+ // A line of text over the page, to narrate a video or a screenshot: it stays until toast off, or for
2
+ // --duration. One per tab; another replaces it.
3
+ const { state, onShutdown } = require('./state');
4
+ const out = require('./output');
5
+ const { tabState } = require('./tabstate');
6
+ const { unquote } = require('./syntax');
7
+ const { takeOptions } = require('./cli-names');
8
+ const { inOverlay, keepAcrossPages, forget } = require('./overlay');
9
+ const { recordingOf, beforeRecordingEnds } = require('./record');
10
+
11
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
12
+
13
+ // While the tab records, a toast stays up at least this long (its read time) before another, toast off
14
+ // or record off may take it down: about the pace captions are read at.
15
+ const READ_MS_PER_WORD = 300;
16
+ const READ_MS_MIN = 1500;
17
+ const LONG_READ_MS = 15000;
18
+ const DEFAULTS = { position: 'bottom', size: 24, align: 'center', color: '#fff', background: 'rgba(20, 20, 20, .85)', opacity: 1 };
19
+
20
+ // Runs in the page, as the overlay's toast layer (lib/overlay.js). A new toast waits for the old one to
21
+ // fade out, then fades in; both are waited on, but not past their time in a tab that draws no frames.
22
+ function pageToast(overlay, { text, style, action, fade }) {
23
+ const { set } = overlay;
24
+ const old = overlay.toast;
25
+ const fadeTo = (el, from, to) => Promise.race([
26
+ el.animate([{ opacity: from }, { opacity: to }], { duration: 200, fill: 'forwards' }).finished,
27
+ new Promise(resolve => setTimeout(resolve, 500)),
28
+ ]).catch(() => {});
29
+ if (action === 'remove') {
30
+ if (!old) return;
31
+ delete overlay.toast;
32
+ return (fade ? fadeTo(old, getComputedStyle(old).opacity, 0) : Promise.resolve()).then(() => {
33
+ old.remove();
34
+ if (!overlay.toast) overlay.drop('toast');
35
+ });
36
+ }
37
+ const box = document.createElement('div');
38
+ overlay.toast = box;
39
+ box.textContent = text;
40
+ const edge = { top: { top: '6%' }, center: { top: '50%' }, bottom: { bottom: '6%' } }[style.position];
41
+ set(box, {
42
+ position: 'absolute', left: '50%', ...edge, transform: `translate(-50%, ${style.position === 'center' ? '-50%' : '0'})`,
43
+ 'max-width': '80%', width: 'max-content', 'box-sizing': 'border-box', padding: `${Math.round(style.size * 0.5)}px ${Math.round(style.size * 0.9)}px`,
44
+ 'border-radius': `${Math.round(style.size * 0.45)}px`, background: style.background, color: style.color, opacity: String(style.opacity),
45
+ font: `500 ${style.size}px/1.35 system-ui, -apple-system, 'Segoe UI', sans-serif`, 'text-align': style.align,
46
+ 'white-space': 'pre-wrap', 'overflow-wrap': 'anywhere', 'box-shadow': '0 4px 18px rgba(0, 0, 0, .3)',
47
+ });
48
+ const out = old && fade ? fadeTo(old, getComputedStyle(old).opacity, 0) : Promise.resolve();
49
+ return out.then(() => {
50
+ old?.remove();
51
+ if (overlay.toast !== box) return;
52
+ overlay.layer('toast').append(box);
53
+ if (fade) return fadeTo(box, 0, style.opacity);
54
+ });
55
+ }
56
+
57
+ function toastOf(p) {
58
+ return tabState(p).toast || null;
59
+ }
60
+
61
+ // Waits out what is left of the toast's read time, while its tab records; says so, when it waited.
62
+ async function waitToRead(p) {
63
+ const toast = toastOf(p);
64
+ if (!toast || !toast.readMs || !recordingOf(p)) return;
65
+ const left = toast.shownAt + toast.readMs - Date.now();
66
+ if (left <= 0) return;
67
+ await sleep(left);
68
+ out.log(`Waited ${(left / 1000).toFixed(1)}s for the toast's read time (the tab is recording)`);
69
+ }
70
+
71
+ async function toastOff(p, fade = true) {
72
+ const toast = toastOf(p);
73
+ if (!toast) return false;
74
+ clearTimeout(toast.timer);
75
+ delete tabState(p).toast;
76
+ forget(p, 'toast');
77
+ await inOverlay(p, pageToast, { action: 'remove', fade }, false).catch(() => {});
78
+ return true;
79
+ }
80
+
81
+ beforeRecordingEnds(p => (p && !p.isClosed() ? waitToRead(p) : null));
82
+
83
+ // 3s, 1.5s, 800ms, or a number of milliseconds.
84
+ function parseDuration(value) {
85
+ const match = /^(\d+(?:\.\d+)?)(ms|s)?$/.exec(value || '');
86
+ if (!match) return null;
87
+ return Math.round(Number(match[1]) * (match[2] === 's' ? 1000 : 1));
88
+ }
89
+
90
+ onShutdown(async () => {
91
+ if (!state.browser || state.connectionLost) return;
92
+ const pages = state.browser.contexts().flatMap(c => c.pages()).filter(p => !p.isClosed());
93
+ await Promise.all(pages.map(p => toastOff(p, false).catch(() => {})));
94
+ });
95
+
96
+ const commands = {
97
+ async toast(args) {
98
+ const p = state.page;
99
+ const line = (args || '').trim();
100
+ const toast = toastOf(p);
101
+ if (!line) {
102
+ out.log(toast
103
+ ? `A toast is up in the selected tab${toast.owner ? ` (by ${toast.owner})` : ''}: ${JSON.stringify(toast.text)}${toast.until ? `, for ${Math.max(0, Math.round((toast.until - Date.now()) / 100) / 10)}s more` : ''}; toast off hides it`
104
+ : 'No toast is up in the selected tab; toast <text> shows one');
105
+ return;
106
+ }
107
+ if (line === 'off') {
108
+ await waitToRead(p);
109
+ out.log(await toastOff(p) ? 'The toast is gone' : 'No toast was up in the selected tab');
110
+ return;
111
+ }
112
+ const usage = 'Usage: toast <text> [--duration=<time>] [--read-time=<time>] [--position=top|center|bottom] [--size=<px>] [--align=left|center|right] [--color=<css>] [--background=<css>] [--opacity=<0-1>] | toast off';
113
+ const { found, rest } = takeOptions(line, { duration: 'value', 'read-time': 'value', position: 'value', size: 'value', align: 'value', color: 'value', background: 'value', opacity: 'value' });
114
+ const text = unquote(rest.trim());
115
+ if (!text) throw new Error(usage);
116
+ const style = { ...DEFAULTS };
117
+ let duration = null;
118
+ let readTime = null;
119
+ for (const { name, value } of found) {
120
+ if (value === undefined || value === '') throw new Error(`--${name} needs a value: ${usage}`);
121
+ if (name === 'duration') {
122
+ duration = parseDuration(value);
123
+ if (!duration) throw new Error(`--duration is a time, e.g. --duration=3s or --duration=1500 (ms): ${usage}`);
124
+ } else if (name === 'read-time') {
125
+ readTime = parseDuration(value);
126
+ if (readTime === null) throw new Error(`--read-time is a time, e.g. --read-time=4s, or 0 for none: ${usage}`);
127
+ } else if (name === 'position') {
128
+ if (!['top', 'center', 'bottom'].includes(value)) throw new Error(`--position is top, center or bottom: ${usage}`);
129
+ style.position = value;
130
+ } else if (name === 'align') {
131
+ if (!['left', 'center', 'right'].includes(value)) throw new Error(`--align is left, center or right: ${usage}`);
132
+ style.align = value;
133
+ } else if (name === 'size') {
134
+ const size = Number(String(value).replace(/px$/, ''));
135
+ if (!(size >= 8 && size <= 200)) throw new Error(`--size is the text's size in pixels, 8 to 200: ${usage}`);
136
+ style.size = size;
137
+ } else if (name === 'opacity') {
138
+ const opacity = Number(value);
139
+ if (!(opacity > 0 && opacity <= 1)) throw new Error(`--opacity is above 0, up to 1: ${usage}`);
140
+ style.opacity = opacity;
141
+ } else {
142
+ style[name] = value;
143
+ }
144
+ }
145
+ if (duration && readTime !== null) throw new Error(`--duration and --read-time do not go together: a toast with a --duration ends by itself, and nothing waits for it`);
146
+ // Replaced, not stacked: the old one fades out, then the new one fades in.
147
+ if (toast) {
148
+ await waitToRead(p);
149
+ clearTimeout(toast.timer);
150
+ }
151
+ const words = text.split(/\s+/).filter(Boolean).length;
152
+ const readMs = duration ? 0 : readTime ?? Math.max(READ_MS_MIN, words * READ_MS_PER_WORD);
153
+ const shown = tabState(p).toast = { owner: state.client, text, style, until: duration ? Date.now() + duration : null, readMs, shownAt: Date.now() };
154
+ await inOverlay(p, pageToast, { text, style, action: 'draw', fade: true });
155
+ shown.shownAt = Date.now();
156
+ keepAcrossPages(p, 'toast', async () => { if (toastOf(p) === shown) await inOverlay(p, pageToast, { text, style, action: 'draw', fade: false }); });
157
+ if (duration) shown.timer = setTimeout(() => { if (toastOf(p) === shown && !p.isClosed()) toastOff(p).catch(() => {}); }, duration);
158
+ if (duration) { out.log(`Showing the toast for ${duration / 1000}s`); return; }
159
+ const why = readTime !== null ? '--read-time' : `${words} word${words === 1 ? '' : 's'}; ${READ_MS_PER_WORD}ms a word, at least ${READ_MS_MIN / 1000}s`;
160
+ // Past send's default 20s, the command that waits would come back unconfirmed while it still waits.
161
+ const long = readMs > LONG_READ_MS ? ` While recording, that one can wait up to ${readMs / 1000}s: send it with -t ${Math.ceil(readMs / 1000) + 10}.` : '';
162
+ out.log(`Showing the toast until toast off. ${readMs ? `Read time: ${readMs / 1000}s (${why}), which a recording waits out before the next toast, toast off or record off.${long}` : 'No read time (--read-time=0).'}`);
163
+ },
164
+ };
165
+
166
+ module.exports = { commands, toastOf, toastOff };