@graphty/visual-review 0.2.0 → 0.2.1
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/README.md +39 -11
- package/capture/capture.mjs +7 -0
- package/package.json +1 -1
- package/trusted/cli.mjs +1 -0
- package/trusted/gate.mjs +7 -1
- package/trusted/lib/accept.mjs +66 -27
- package/trusted/lib/config.mjs +4 -3
- package/trusted/lib/github.mjs +100 -20
- package/trusted/lib/serve.mjs +444 -110
- package/trusted/page/review.css +19 -0
- package/trusted/page/review.js +326 -97
package/trusted/page/review.js
CHANGED
|
@@ -51,6 +51,7 @@ const state = {
|
|
|
51
51
|
box: 0, // which changed box "next changed box" is on
|
|
52
52
|
showBox: saved.showBox ?? true, // outline the changed box (B)
|
|
53
53
|
blink: saved.blink ?? false, // blink the changed pixels Highlight lays over the images (L)
|
|
54
|
+
spotFlash: saved.spotFlash ?? false, // Spotlight flashes baseline and new (F in Spotlight)
|
|
54
55
|
held: null, // the view to return to when Space is released
|
|
55
56
|
pending: "reject", // what Enter in the reason box does
|
|
56
57
|
screen: "targets",
|
|
@@ -60,9 +61,21 @@ const state = {
|
|
|
60
61
|
const running = () => state.job?.running === true;
|
|
61
62
|
const VIEWS = ["side", "flash", "highlight", "spotlight"];
|
|
62
63
|
const FILTERS = ["undecided", "all", ...REVIEWABLE, ...Object.keys(DECISIONS)];
|
|
63
|
-
let
|
|
64
|
+
let routes = 0; // how many routes are running: the page follows the address (a link, Back, Forward)
|
|
65
|
+
let nav = 0; // bumped by each screen change that waits on the server: a superseded one stops there
|
|
66
|
+
// The least recently used last-opened images and diffs are dropped past these, so a long session
|
|
67
|
+
// stays small: an object URL holds its image, and a diff about 13 bytes per image pixel.
|
|
64
68
|
const images = new Map();
|
|
69
|
+
const IMAGES_KEPT = 50;
|
|
65
70
|
const diffs = new Map();
|
|
71
|
+
const DIFFS_KEPT = 4;
|
|
72
|
+
let stageRender = 0; // the newest renderStage: an older one finishing late writes nothing
|
|
73
|
+
let stageShown = null; // the file whose two panes are on the stage: Accept waits for it
|
|
74
|
+
let deciding = false; // a decision is on its way to the server
|
|
75
|
+
// A decision moves on to the next item, whose buttons are in the same place: the second click of
|
|
76
|
+
// a double click, this soon after the first, lands there and is ignored.
|
|
77
|
+
const DOUBLE_CLICK_MS = 500;
|
|
78
|
+
let clickedAt = -Infinity; // when a decision button was last clicked
|
|
66
79
|
let flashTimer = null;
|
|
67
80
|
let factor = 1; // CSS pixels per image pixel of the pictures on the stage
|
|
68
81
|
let shownBoxes = []; // the changed boxes of the item on the stage
|
|
@@ -103,6 +116,32 @@ function say(text, isError = false) {
|
|
|
103
116
|
statusLine.className = isError ? "error" : "";
|
|
104
117
|
}
|
|
105
118
|
|
|
119
|
+
// Asks in the page, never with confirm(): once a browser stops a page's dialogs ("prevent this
|
|
120
|
+
// page from creating additional dialogs"), confirm() returns false without showing anything, and
|
|
121
|
+
// the button pressed seems to do nothing. Resolves true for `yes`, false for Cancel or Escape.
|
|
122
|
+
function ask(message, yes) {
|
|
123
|
+
// Focus on the box, not a button: the Enter that asked (in the reason box) must not answer it.
|
|
124
|
+
const dialog = el("dialog", { class: "ask", tabindex: "-1" }, el("p", {}, message));
|
|
125
|
+
const answer = (value) => () => dialog.close(value);
|
|
126
|
+
dialog.append(
|
|
127
|
+
el(
|
|
128
|
+
"p",
|
|
129
|
+
{ class: "actions" },
|
|
130
|
+
el("button", { type: "button", onclick: answer("no") }, "Cancel"),
|
|
131
|
+
el("button", { type: "button", class: "primary", onclick: answer("yes") }, yes),
|
|
132
|
+
),
|
|
133
|
+
);
|
|
134
|
+
document.body.append(dialog);
|
|
135
|
+
dialog.showModal();
|
|
136
|
+
dialog.focus();
|
|
137
|
+
return new Promise((resolve) =>
|
|
138
|
+
dialog.addEventListener("close", () => {
|
|
139
|
+
dialog.remove();
|
|
140
|
+
resolve(dialog.returnValue === "yes");
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
106
145
|
async function api(path, body) {
|
|
107
146
|
const res = await fetch(path, {
|
|
108
147
|
method: body ? "POST" : "GET",
|
|
@@ -116,29 +155,55 @@ async function api(path, body) {
|
|
|
116
155
|
return json;
|
|
117
156
|
}
|
|
118
157
|
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
158
|
+
// The promise `make` returns for `key`, kept in `map` with the least recently used dropped past
|
|
159
|
+
// `limit` (and handed to `drop`). A promise that fails is dropped at once, so the next use retries.
|
|
160
|
+
function cached(map, key, limit, make, drop = () => {}) {
|
|
161
|
+
let value = map.get(key);
|
|
162
|
+
if (value === undefined) {
|
|
163
|
+
value = make();
|
|
164
|
+
value.catch(() => {
|
|
165
|
+
if (map.get(key) === value) {
|
|
166
|
+
map.delete(key);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
map.delete(key);
|
|
171
|
+
map.set(key, value);
|
|
172
|
+
for (const [k, old] of map) {
|
|
173
|
+
if (map.size <= limit) {
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
map.delete(k);
|
|
177
|
+
drop(old);
|
|
178
|
+
}
|
|
179
|
+
return value;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Loads an image through the API (it needs the token header) and returns an object URL. Keyed by
|
|
183
|
+
// the image's hash too, so a newer CI run's image is never answered with an older one.
|
|
184
|
+
function image(kind, file, hash) {
|
|
185
|
+
return cached(
|
|
186
|
+
images,
|
|
187
|
+
`${state.target.id}/${state.project}/${kind}/${file}/${hash}`,
|
|
188
|
+
IMAGES_KEPT,
|
|
189
|
+
() => {
|
|
190
|
+
const path = ["img", state.target.id, state.project, kind, file].map(encodeURIComponent).join("/");
|
|
191
|
+
return fetch(`/api/${path}`, { headers: { "x-review-token": token } }).then(async (res) => {
|
|
127
192
|
if (!res.ok) {
|
|
128
193
|
throw new Error(`${file}: ${(await res.json().catch(() => ({}))).error ?? res.status}`);
|
|
129
194
|
}
|
|
130
195
|
return URL.createObjectURL(await res.blob());
|
|
131
|
-
})
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
196
|
+
});
|
|
197
|
+
},
|
|
198
|
+
(url) => url.then(URL.revokeObjectURL, () => {}),
|
|
199
|
+
);
|
|
135
200
|
}
|
|
136
201
|
|
|
137
|
-
function loaded(url) {
|
|
202
|
+
function loaded(url, what) {
|
|
138
203
|
return new Promise((resolve, reject) => {
|
|
139
204
|
const img = new Image();
|
|
140
205
|
img.onload = () => resolve(img);
|
|
141
|
-
img.onerror = reject;
|
|
206
|
+
img.onerror = () => reject(new Error(`${what}: the image could not be shown`));
|
|
142
207
|
img.src = url;
|
|
143
208
|
});
|
|
144
209
|
}
|
|
@@ -149,6 +214,9 @@ const itemName = (item) => (item.mode ? `${item.id} (${item.mode})` : item.id);
|
|
|
149
214
|
const movedFrom = (item) => (item.from ? `moved from ${item.from}` : "");
|
|
150
215
|
const short = (sha) => (sha ? sha.slice(0, 10) : "none");
|
|
151
216
|
const decisionOf = (item) => state.data?.decisions[item.file] ?? null;
|
|
217
|
+
// The image a decision is about, as the server checks it: a decision on an image another run
|
|
218
|
+
// replaced since this page loaded is refused.
|
|
219
|
+
const imageHash = (item) => item.capture ?? item.baseline ?? null;
|
|
152
220
|
const isLocal = () => state.target?.local === true;
|
|
153
221
|
// Device pixels per CSS pixel of this capture: 2 today, 1 for captures made before it was recorded.
|
|
154
222
|
const scale = () => state.data?.results.scale ?? 1;
|
|
@@ -228,7 +296,10 @@ function loadOptions() {
|
|
|
228
296
|
|
|
229
297
|
function saveOptions() {
|
|
230
298
|
try {
|
|
231
|
-
localStorage.setItem(
|
|
299
|
+
localStorage.setItem(
|
|
300
|
+
OPTIONS_KEY,
|
|
301
|
+
JSON.stringify({ showBox: state.showBox, blink: state.blink, spotFlash: state.spotFlash }),
|
|
302
|
+
);
|
|
232
303
|
} catch {
|
|
233
304
|
// Not remembered; the choice still holds for this page.
|
|
234
305
|
}
|
|
@@ -247,7 +318,7 @@ async function loadTargets() {
|
|
|
247
318
|
try {
|
|
248
319
|
const [prs, status] = await Promise.all([api("/api/prs"), api("/api/finish-status")]);
|
|
249
320
|
state.targets = prs.targets;
|
|
250
|
-
|
|
321
|
+
track(status.job);
|
|
251
322
|
say("");
|
|
252
323
|
if (running()) {
|
|
253
324
|
// A reload during a Finish, on any screen: follow the running one, never offer a second.
|
|
@@ -261,10 +332,11 @@ async function loadTargets() {
|
|
|
261
332
|
}
|
|
262
333
|
|
|
263
334
|
async function showTargets(notice = "") {
|
|
335
|
+
const seq = ++nav;
|
|
264
336
|
stopFlash();
|
|
265
337
|
state.screen = "targets";
|
|
266
338
|
setCrumbs();
|
|
267
|
-
if (!(await loadTargets())) {
|
|
339
|
+
if (!(await loadTargets()) || seq !== nav) {
|
|
268
340
|
return;
|
|
269
341
|
}
|
|
270
342
|
remember();
|
|
@@ -400,7 +472,7 @@ function targetCard(t) {
|
|
|
400
472
|
type: "button",
|
|
401
473
|
class: "primary",
|
|
402
474
|
disabled: decided === 0 || running(),
|
|
403
|
-
onclick: () => finishTarget(t),
|
|
475
|
+
onclick: (e) => finishTarget(t, e.currentTarget),
|
|
404
476
|
},
|
|
405
477
|
`Finish ${t.pr === null ? "seed" : `#${t.pr}`} (${decided} decisions)`,
|
|
406
478
|
),
|
|
@@ -411,6 +483,7 @@ function targetCard(t) {
|
|
|
411
483
|
// ---------------------------------------------------------------- screen: project grid
|
|
412
484
|
|
|
413
485
|
async function openProject(target, project) {
|
|
486
|
+
const seq = ++nav;
|
|
414
487
|
state.target = target;
|
|
415
488
|
state.project = project;
|
|
416
489
|
state.filter = "undecided";
|
|
@@ -425,6 +498,9 @@ async function openProject(target, project) {
|
|
|
425
498
|
say(err.message, true);
|
|
426
499
|
return;
|
|
427
500
|
}
|
|
501
|
+
if (seq !== nav) {
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
428
504
|
// Nothing left to decide (or a local preview): show everything instead of an empty grid.
|
|
429
505
|
if (visibleItems().length === 0) {
|
|
430
506
|
state.filter = "all";
|
|
@@ -444,8 +520,8 @@ const thumbs = new IntersectionObserver((entries) => {
|
|
|
444
520
|
for (const e of entries) {
|
|
445
521
|
if (e.isIntersecting) {
|
|
446
522
|
thumbs.unobserve(e.target);
|
|
447
|
-
const { kind, file } = e.target.dataset;
|
|
448
|
-
image(kind, file).then(
|
|
523
|
+
const { kind, file, hash } = e.target.dataset;
|
|
524
|
+
image(kind, file, hash).then(
|
|
449
525
|
(url) => (e.target.src = url),
|
|
450
526
|
(err) => (e.target.alt = err.message),
|
|
451
527
|
);
|
|
@@ -466,7 +542,7 @@ function openItem(index) {
|
|
|
466
542
|
function tile(item, number) {
|
|
467
543
|
const kind = item.capture ? "capture" : item.baseline ? "baseline" : null;
|
|
468
544
|
const img = kind
|
|
469
|
-
? el("img", { "data-kind": kind, "data-file": item.file, alt: itemName(item) })
|
|
545
|
+
? el("img", { "data-kind": kind, "data-file": item.file, "data-hash": item[kind], alt: itemName(item) })
|
|
470
546
|
: el("div", { class: "noimage" }, item.status);
|
|
471
547
|
if (kind) {
|
|
472
548
|
thumbs.observe(img);
|
|
@@ -543,11 +619,12 @@ function errorRow(item, number) {
|
|
|
543
619
|
);
|
|
544
620
|
}
|
|
545
621
|
|
|
546
|
-
//
|
|
547
|
-
const
|
|
622
|
+
// The items of `component` Accept would take without opening them.
|
|
623
|
+
const undecided = (component) =>
|
|
548
624
|
state.data.items.filter(
|
|
549
625
|
(i) => ACCEPTABLE.includes(i.status) && !decisionOf(i) && (!component || componentOf(i.id) === component),
|
|
550
|
-
)
|
|
626
|
+
);
|
|
627
|
+
const undecidedIn = (component) => undecided(component).length;
|
|
551
628
|
|
|
552
629
|
// The files whose decisions a bulk Undo in `component` (or the whole project) clears: every
|
|
553
630
|
// decision not yet posted by Finish.
|
|
@@ -621,7 +698,9 @@ async function undo(files, where, one = false) {
|
|
|
621
698
|
|
|
622
699
|
function showGrid() {
|
|
623
700
|
stopFlash();
|
|
701
|
+
thumbs.disconnect();
|
|
624
702
|
state.screen = "grid";
|
|
703
|
+
state.pending = "reject";
|
|
625
704
|
setCrumbs(el("span", {}, targetLabel()), el("span", {}, state.project));
|
|
626
705
|
const items = visibleItems();
|
|
627
706
|
const counts = {};
|
|
@@ -847,14 +926,28 @@ function showStory() {
|
|
|
847
926
|
}
|
|
848
927
|
state.index = Math.max(0, Math.min(state.index, items.length - 1));
|
|
849
928
|
const item = items[state.index];
|
|
929
|
+
if (item.file !== state.lastFile) {
|
|
930
|
+
// An Exclude (or reject) left waiting for a reason on another item is abandoned.
|
|
931
|
+
state.pending = "reject";
|
|
932
|
+
stageShown = null;
|
|
933
|
+
}
|
|
850
934
|
state.lastFile = item.file;
|
|
851
935
|
const d = decisionOf(item);
|
|
852
936
|
if (d?.bulk && !isLocal()) {
|
|
853
|
-
// Opening an item Accept all decided counts it as opened.
|
|
937
|
+
// Opening an item Accept all decided counts it as opened. `opened` never decides, so a stale
|
|
938
|
+
// grid cannot bring back an accept another tab undid or a Finish committed; the decisions
|
|
939
|
+
// are then read again, so such an item shows as undecided.
|
|
854
940
|
delete d.bulk;
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
941
|
+
const { id } = state.target;
|
|
942
|
+
api("/api/decide", { id, project: state.project, file: item.file, opened: true, hash: imageHash(item) })
|
|
943
|
+
.then(reload)
|
|
944
|
+
.then(() => {
|
|
945
|
+
if (!decisionOf(item) && state.screen === "story" && state.lastFile === item.file) {
|
|
946
|
+
showStory();
|
|
947
|
+
say(`${itemName(item)}: its accept was undone or finished elsewhere; undecided now`);
|
|
948
|
+
}
|
|
949
|
+
})
|
|
950
|
+
.catch((err) => say(err.message, true));
|
|
858
951
|
}
|
|
859
952
|
const note = singleImageNote(item);
|
|
860
953
|
const view = note ? "side" : state.view;
|
|
@@ -901,11 +994,11 @@ function showStory() {
|
|
|
901
994
|
const reason = el("input", {
|
|
902
995
|
id: "reason",
|
|
903
996
|
type: "text",
|
|
904
|
-
placeholder:
|
|
997
|
+
placeholder: reasonHint(),
|
|
905
998
|
value: d?.reason ?? "",
|
|
906
999
|
maxlength: "2000",
|
|
907
1000
|
onkeydown: (e) => {
|
|
908
|
-
if (e.key === "Enter") {
|
|
1001
|
+
if (e.key === "Enter" && !e.repeat) {
|
|
909
1002
|
decide(state.pending);
|
|
910
1003
|
}
|
|
911
1004
|
},
|
|
@@ -919,14 +1012,21 @@ function showStory() {
|
|
|
919
1012
|
state.data.acceptable && !onlyExclude
|
|
920
1013
|
? el(
|
|
921
1014
|
"button",
|
|
922
|
-
{
|
|
1015
|
+
{
|
|
1016
|
+
type: "button",
|
|
1017
|
+
class: "accept",
|
|
1018
|
+
onclick: () => clicked("accept"),
|
|
1019
|
+
title: "A",
|
|
1020
|
+
// Until both images are shown: renderStage enables it.
|
|
1021
|
+
disabled: true,
|
|
1022
|
+
},
|
|
923
1023
|
"Accept",
|
|
924
1024
|
)
|
|
925
1025
|
: null,
|
|
926
1026
|
!onlyExclude
|
|
927
1027
|
? el(
|
|
928
1028
|
"button",
|
|
929
|
-
{ type: "button", class: "reject", onclick: () =>
|
|
1029
|
+
{ type: "button", class: "reject", onclick: () => clicked("reject"), title: "R" },
|
|
930
1030
|
"Reject",
|
|
931
1031
|
)
|
|
932
1032
|
: null,
|
|
@@ -935,7 +1035,7 @@ function showStory() {
|
|
|
935
1035
|
"button",
|
|
936
1036
|
{
|
|
937
1037
|
type: "button",
|
|
938
|
-
onclick: () =>
|
|
1038
|
+
onclick: () => clicked("exclude"),
|
|
939
1039
|
title: "E: stop capturing every mode of this story",
|
|
940
1040
|
},
|
|
941
1041
|
"Exclude",
|
|
@@ -1021,6 +1121,20 @@ function showStory() {
|
|
|
1021
1121
|
},
|
|
1022
1122
|
"Blink",
|
|
1023
1123
|
),
|
|
1124
|
+
el(
|
|
1125
|
+
"button",
|
|
1126
|
+
{
|
|
1127
|
+
type: "button",
|
|
1128
|
+
"aria-pressed": String(state.spotFlash),
|
|
1129
|
+
disabled: view !== "spotlight",
|
|
1130
|
+
title:
|
|
1131
|
+
view === "spotlight"
|
|
1132
|
+
? "F: flash the spotlighted baseline and new"
|
|
1133
|
+
: "Spotlight flash shows the spotlighted baseline and new one after the other",
|
|
1134
|
+
onclick: () => toggleOption("spotFlash"),
|
|
1135
|
+
},
|
|
1136
|
+
"Spotlight flash",
|
|
1137
|
+
),
|
|
1024
1138
|
note ? el("span", { id: "single-note", class: "meta" }, note) : null,
|
|
1025
1139
|
el("span", { class: "spacer" }),
|
|
1026
1140
|
ZOOMS.map(zoomButton),
|
|
@@ -1076,37 +1190,35 @@ function showStory() {
|
|
|
1076
1190
|
remember();
|
|
1077
1191
|
}
|
|
1078
1192
|
|
|
1193
|
+
const reasonHint = () => `Reason (needed to reject or exclude); Enter ${state.pending}s`;
|
|
1194
|
+
|
|
1079
1195
|
// The changed pixels of an item, padded top-left to the larger size, grown by GROW pixels, and the
|
|
1080
|
-
// boxes around each separate grown region. Computed once per item.
|
|
1196
|
+
// boxes around each separate grown region. Computed once per item and pair of images.
|
|
1081
1197
|
async function diffOf(item) {
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
})(),
|
|
1107
|
-
);
|
|
1108
|
-
}
|
|
1109
|
-
return diffs.get(item.file);
|
|
1198
|
+
const { file, baseline, capture, threshold, includeAA } = item;
|
|
1199
|
+
const key = `${state.target.id}/${state.project}/${file}/${baseline}/${capture}/${threshold}/${includeAA}`;
|
|
1200
|
+
return cached(diffs, key, DIFFS_KEPT, async () => {
|
|
1201
|
+
const [a, b] = await Promise.all([
|
|
1202
|
+
loaded(await image("baseline", item.file, item.baseline), `baseline of ${item.file}`),
|
|
1203
|
+
loaded(await image("capture", item.file, item.capture), `capture of ${item.file}`),
|
|
1204
|
+
]);
|
|
1205
|
+
const w = Math.max(a.naturalWidth, b.naturalWidth);
|
|
1206
|
+
const h = Math.max(a.naturalHeight, b.naturalHeight);
|
|
1207
|
+
const pixels = (img) => {
|
|
1208
|
+
const ctx = new OffscreenCanvas(w, h).getContext("2d");
|
|
1209
|
+
ctx.drawImage(img, 0, 0);
|
|
1210
|
+
return ctx.getImageData(0, 0, w, h).data;
|
|
1211
|
+
};
|
|
1212
|
+
const [pa, pb] = [pixels(a), pixels(b)];
|
|
1213
|
+
const mask = new Uint8ClampedArray(w * h * 4);
|
|
1214
|
+
pixelmatch(pa, pb, mask, w, h, {
|
|
1215
|
+
threshold: item.threshold,
|
|
1216
|
+
includeAA: item.includeAA,
|
|
1217
|
+
diffMask: true,
|
|
1218
|
+
});
|
|
1219
|
+
const grown = grow(mask, w, h);
|
|
1220
|
+
return { w, h, a: pa, b: pb, mask, grown, boxes: regions(grown, w, h) };
|
|
1221
|
+
});
|
|
1110
1222
|
}
|
|
1111
1223
|
|
|
1112
1224
|
// A square dilation by GROW pixels, as two passes (rows, then columns) of a sliding window.
|
|
@@ -1208,6 +1320,7 @@ function fit(stage) {
|
|
|
1208
1320
|
// Two panes, always: the baseline on the left and the new image (or the view's picture) on the
|
|
1209
1321
|
// right. A missing image leaves its pane empty, the same size, so the other one never moves.
|
|
1210
1322
|
async function renderStage(item, view, keep) {
|
|
1323
|
+
const seq = ++stageRender;
|
|
1211
1324
|
const stage = document.getElementById("stage");
|
|
1212
1325
|
const pane = (text, ...pics) =>
|
|
1213
1326
|
el(
|
|
@@ -1221,7 +1334,7 @@ async function renderStage(item, view, keep) {
|
|
|
1221
1334
|
),
|
|
1222
1335
|
);
|
|
1223
1336
|
const imgOf = async (kind) => {
|
|
1224
|
-
const img = await loaded(await image(kind, item.file));
|
|
1337
|
+
const img = await loaded(await image(kind, item.file, item[kind]), `${kind} of ${item.file}`);
|
|
1225
1338
|
img.alt = `${kind} of ${itemName(item)}`;
|
|
1226
1339
|
return img;
|
|
1227
1340
|
};
|
|
@@ -1241,11 +1354,18 @@ async function renderStage(item, view, keep) {
|
|
|
1241
1354
|
right = pane(item.status === "failed" ? "No capture: it failed" : "No capture");
|
|
1242
1355
|
} else if (view === "side") {
|
|
1243
1356
|
right = pane(item.baseline ? "New" : "New (no baseline)", await imgOf("capture"));
|
|
1244
|
-
} else if (view === "flash") {
|
|
1245
|
-
// The two images
|
|
1246
|
-
const
|
|
1357
|
+
} else if (view === "flash" || (view === "spotlight" && state.spotFlash)) {
|
|
1358
|
+
// The two images one after the other in the same place: themselves, or both spotlighted.
|
|
1359
|
+
const flash = view === "flash";
|
|
1360
|
+
const name = flash ? "Flash" : "Spotlight";
|
|
1361
|
+
const [base, next] = flash
|
|
1362
|
+
? [await imgOf("baseline"), await imgOf("capture")]
|
|
1363
|
+
: [spotlight(diff, diff.a), spotlight(diff, diff.b)];
|
|
1364
|
+
if (seq !== stageRender) {
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1247
1367
|
next.style.visibility = "hidden";
|
|
1248
|
-
right = pane(
|
|
1368
|
+
right = pane(`${name}: baseline`, base, next);
|
|
1249
1369
|
right.classList.add("flashing");
|
|
1250
1370
|
const tag = right.querySelector(".label");
|
|
1251
1371
|
let showingNew = false;
|
|
@@ -1253,14 +1373,23 @@ async function renderStage(item, view, keep) {
|
|
|
1253
1373
|
showingNew = !showingNew;
|
|
1254
1374
|
base.style.visibility = showingNew ? "hidden" : "visible";
|
|
1255
1375
|
next.style.visibility = showingNew ? "visible" : "hidden";
|
|
1256
|
-
tag.textContent = showingNew ?
|
|
1376
|
+
tag.textContent = showingNew ? `${name}: new` : `${name}: baseline`;
|
|
1257
1377
|
}, FLASH_MS);
|
|
1258
1378
|
} else if (view === "highlight") {
|
|
1259
1379
|
right = pane("New, changed pixels in red", await imgOf("capture"), overlay(diff));
|
|
1260
1380
|
} else {
|
|
1261
1381
|
right = pane("Spotlight: the new image, dimmed except around each change", spotlight(diff));
|
|
1262
1382
|
}
|
|
1383
|
+
if (seq !== stageRender) {
|
|
1384
|
+
// Another item (or view) was shown while this one loaded.
|
|
1385
|
+
return;
|
|
1386
|
+
}
|
|
1263
1387
|
stage.replaceChildren(left, right);
|
|
1388
|
+
stageShown = item.file;
|
|
1389
|
+
const accept = document.querySelector(".actions button.accept");
|
|
1390
|
+
if (accept) {
|
|
1391
|
+
accept.disabled = false;
|
|
1392
|
+
}
|
|
1264
1393
|
if (marked && state.blink) {
|
|
1265
1394
|
// Both panes' overlays on and off together, at Flash's pace.
|
|
1266
1395
|
const marks = [...stage.querySelectorAll(".diffmark")];
|
|
@@ -1303,7 +1432,9 @@ async function renderStage(item, view, keep) {
|
|
|
1303
1432
|
}
|
|
1304
1433
|
}
|
|
1305
1434
|
} catch (err) {
|
|
1306
|
-
|
|
1435
|
+
if (seq === stageRender) {
|
|
1436
|
+
stage.replaceChildren(el("p", { class: "error" }, err.message || "the images could not be loaded"));
|
|
1437
|
+
}
|
|
1307
1438
|
}
|
|
1308
1439
|
}
|
|
1309
1440
|
|
|
@@ -1388,8 +1519,8 @@ function overlay(diff) {
|
|
|
1388
1519
|
return canvas;
|
|
1389
1520
|
}
|
|
1390
1521
|
|
|
1391
|
-
//
|
|
1392
|
-
function spotlight(diff) {
|
|
1522
|
+
// An image (the new one unless given) with everything dimmed except the changed pixels grown by GROW pixels.
|
|
1523
|
+
function spotlight(diff, px = diff.b) {
|
|
1393
1524
|
const canvas = el("canvas", { width: String(diff.w), height: String(diff.h) });
|
|
1394
1525
|
const ctx = canvas.getContext("2d");
|
|
1395
1526
|
const out = ctx.createImageData(diff.w, diff.h);
|
|
@@ -1397,9 +1528,9 @@ function spotlight(diff) {
|
|
|
1397
1528
|
for (let i = 0; i < diff.w * diff.h; i++) {
|
|
1398
1529
|
const lit = diff.grown[i] === 1;
|
|
1399
1530
|
for (let c = 0; c < 3; c++) {
|
|
1400
|
-
out.data[i * 4 + c] = lit ?
|
|
1531
|
+
out.data[i * 4 + c] = lit ? px[i * 4 + c] : px[i * 4 + c] * keep;
|
|
1401
1532
|
}
|
|
1402
|
-
out.data[i * 4 + 3] = lit ?
|
|
1533
|
+
out.data[i * 4 + 3] = lit ? px[i * 4 + 3] : Math.max(px[i * 4 + 3], SPOT_ALPHA);
|
|
1403
1534
|
}
|
|
1404
1535
|
ctx.putImageData(out, 0, 0);
|
|
1405
1536
|
return canvas;
|
|
@@ -1410,19 +1541,28 @@ async function acceptAll(component) {
|
|
|
1410
1541
|
say(`${state.project} cannot be accepted here`, true);
|
|
1411
1542
|
return;
|
|
1412
1543
|
}
|
|
1413
|
-
const
|
|
1544
|
+
const items = undecided(component);
|
|
1545
|
+
const n = items.length;
|
|
1414
1546
|
const where = component ? `the component ${component}` : state.project;
|
|
1415
1547
|
if (n === 0) {
|
|
1416
1548
|
say(`Nothing undecided to accept in ${where}.`);
|
|
1417
1549
|
return;
|
|
1418
1550
|
}
|
|
1419
|
-
|
|
1551
|
+
// Accepting a removal deletes its baseline, so the question says how many it holds.
|
|
1552
|
+
const removals = items.filter((i) => i.status === "removed").length;
|
|
1553
|
+
const deletes =
|
|
1554
|
+
removals === 0
|
|
1555
|
+
? ""
|
|
1556
|
+
: ` This includes ${removals} ${removals === 1 ? "removal" : "removals"}: accepting deletes ${removals === 1 ? "its baseline" : "their baselines"}.`;
|
|
1557
|
+
if (!(await ask(`Accept ${n} undecided items of ${where} without opening them?${deletes}`, "Accept"))) {
|
|
1420
1558
|
return;
|
|
1421
1559
|
}
|
|
1422
1560
|
try {
|
|
1423
1561
|
await api("/api/accept-all", {
|
|
1424
1562
|
id: state.target.id,
|
|
1425
1563
|
project: state.project,
|
|
1564
|
+
runId: state.data.target.runId,
|
|
1565
|
+
runAttempt: state.data.target.runAttempt,
|
|
1426
1566
|
...(component ? { component } : {}),
|
|
1427
1567
|
});
|
|
1428
1568
|
await reload();
|
|
@@ -1447,6 +1587,10 @@ async function decide(decision) {
|
|
|
1447
1587
|
say("A local preview is only looked at: nothing is decided on it.", true);
|
|
1448
1588
|
return;
|
|
1449
1589
|
}
|
|
1590
|
+
if (deciding) {
|
|
1591
|
+
// A second click or key while the first decision is on its way.
|
|
1592
|
+
return;
|
|
1593
|
+
}
|
|
1450
1594
|
const items = passItems();
|
|
1451
1595
|
const item = items[state.index];
|
|
1452
1596
|
const before = decisionOf(item);
|
|
@@ -1459,23 +1603,32 @@ async function decide(decision) {
|
|
|
1459
1603
|
say(`${itemName(item)} is already ${done}. Press U (Undo) first to change it.`, true);
|
|
1460
1604
|
return;
|
|
1461
1605
|
}
|
|
1606
|
+
if (decision === "accept" && stageShown !== item.file) {
|
|
1607
|
+
say(`${itemName(item)}: wait for both images before accepting.`, true);
|
|
1608
|
+
return;
|
|
1609
|
+
}
|
|
1462
1610
|
const reasonBox = document.getElementById("reason");
|
|
1463
1611
|
const reason = reasonBox?.value.trim() ?? "";
|
|
1464
1612
|
if ((decision === "reject" || decision === "exclude") && reason === "") {
|
|
1465
1613
|
state.pending = decision;
|
|
1466
|
-
reasonBox
|
|
1614
|
+
if (reasonBox) {
|
|
1615
|
+
reasonBox.placeholder = reasonHint();
|
|
1616
|
+
reasonBox.focus();
|
|
1617
|
+
}
|
|
1467
1618
|
say(`A ${decision} needs a reason: type it, then press Enter.`);
|
|
1468
1619
|
return;
|
|
1469
1620
|
}
|
|
1470
1621
|
if (
|
|
1471
1622
|
decision === "exclude" &&
|
|
1472
|
-
!
|
|
1623
|
+
!(await ask(
|
|
1473
1624
|
`Exclude ${item.id}? Every mode of this story stops being captured, on every pull request, ` +
|
|
1474
1625
|
"until its settings file is removed. To clear a one-off failure, re-run the visual job instead.",
|
|
1475
|
-
|
|
1626
|
+
"Exclude",
|
|
1627
|
+
))
|
|
1476
1628
|
) {
|
|
1477
1629
|
return;
|
|
1478
1630
|
}
|
|
1631
|
+
deciding = true;
|
|
1479
1632
|
try {
|
|
1480
1633
|
await api("/api/decide", {
|
|
1481
1634
|
id: state.target.id,
|
|
@@ -1483,10 +1636,13 @@ async function decide(decision) {
|
|
|
1483
1636
|
file: item.file,
|
|
1484
1637
|
decision,
|
|
1485
1638
|
reason: reason === "" ? null : reason,
|
|
1639
|
+
hash: imageHash(item),
|
|
1486
1640
|
});
|
|
1487
1641
|
} catch (err) {
|
|
1488
1642
|
say(err.message, true);
|
|
1489
1643
|
return;
|
|
1644
|
+
} finally {
|
|
1645
|
+
deciding = false;
|
|
1490
1646
|
}
|
|
1491
1647
|
if (decision === null) {
|
|
1492
1648
|
delete state.data.decisions[item.file];
|
|
@@ -1504,6 +1660,15 @@ async function decide(decision) {
|
|
|
1504
1660
|
showStory();
|
|
1505
1661
|
}
|
|
1506
1662
|
|
|
1663
|
+
// A decision button's click; a double click's second one is not a decision on the next item.
|
|
1664
|
+
function clicked(decision) {
|
|
1665
|
+
const now = performance.now();
|
|
1666
|
+
if (now - clickedAt >= DOUBLE_CLICK_MS) {
|
|
1667
|
+
clickedAt = now;
|
|
1668
|
+
decide(decision);
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1507
1672
|
// ---------------------------------------------------------------- Finish
|
|
1508
1673
|
|
|
1509
1674
|
function finishButton() {
|
|
@@ -1512,24 +1677,36 @@ function finishButton() {
|
|
|
1512
1677
|
}
|
|
1513
1678
|
return el(
|
|
1514
1679
|
"button",
|
|
1515
|
-
{
|
|
1680
|
+
{
|
|
1681
|
+
type: "button",
|
|
1682
|
+
class: "primary",
|
|
1683
|
+
disabled: running(),
|
|
1684
|
+
onclick: (e) => finishTarget(state.target, e.currentTarget),
|
|
1685
|
+
},
|
|
1516
1686
|
`Finish ${targetLabel()}`,
|
|
1517
1687
|
);
|
|
1518
1688
|
}
|
|
1519
1689
|
|
|
1520
|
-
|
|
1690
|
+
// `button` is the Finish button pressed: off from the press until Finish is started or given up,
|
|
1691
|
+
// so the press shows at once, even while the server is slow to answer.
|
|
1692
|
+
async function finishTarget(target, button) {
|
|
1693
|
+
const label = target.pr === null ? "the master seed" : `#${target.pr}`;
|
|
1521
1694
|
const what =
|
|
1522
1695
|
target.pr === null ? "push a seed branch and open its pull request" : `commit and push to ${target.branch}`;
|
|
1696
|
+
const giveUp = (text, isError) => {
|
|
1697
|
+
button.disabled = running();
|
|
1698
|
+
say(text, isError);
|
|
1699
|
+
};
|
|
1700
|
+
button.disabled = true;
|
|
1701
|
+
say(`Checking ${label} before Finish...`);
|
|
1523
1702
|
let fresh;
|
|
1524
1703
|
try {
|
|
1525
1704
|
fresh = await api(`/api/target/${encodeURIComponent(target.id)}`);
|
|
1526
1705
|
} catch (err) {
|
|
1527
|
-
|
|
1706
|
+
giveUp(`Finish not started: ${err.message}`, true);
|
|
1528
1707
|
return;
|
|
1529
1708
|
}
|
|
1530
|
-
const lines = [
|
|
1531
|
-
`Finish ${target.pr === null ? "the master seed" : `#${target.pr}`}: ${what}, across every project?`,
|
|
1532
|
-
];
|
|
1709
|
+
const lines = [`Finish ${label}: ${what}, across every project?`];
|
|
1533
1710
|
const notOpened = fresh.projects.reduce((n, p) => n + p.notOpened, 0);
|
|
1534
1711
|
if (notOpened > 0) {
|
|
1535
1712
|
lines.push(`${notOpened} accepted without being opened.`);
|
|
@@ -1547,7 +1724,9 @@ async function finishTarget(target) {
|
|
|
1547
1724
|
);
|
|
1548
1725
|
}
|
|
1549
1726
|
lines.push("One commit status is posted when Finish completes.");
|
|
1550
|
-
|
|
1727
|
+
say(`Finish ${label}? Answer in the box.`);
|
|
1728
|
+
if (!(await ask(lines.join("\n\n"), `Finish ${label}`))) {
|
|
1729
|
+
giveUp("Finish cancelled: nothing was changed.");
|
|
1551
1730
|
return;
|
|
1552
1731
|
}
|
|
1553
1732
|
// Finish runs on the server and can take minutes; the page only starts it and then asks how it
|
|
@@ -1561,7 +1740,7 @@ async function finishTarget(target) {
|
|
|
1561
1740
|
.then((s) => s.job)
|
|
1562
1741
|
.catch(() => null);
|
|
1563
1742
|
if (!running() || state.job.target !== target.id) {
|
|
1564
|
-
|
|
1743
|
+
giveUp("Finish failed; your decisions are kept. Fix the cause and press Finish again.", true);
|
|
1565
1744
|
app.prepend(el("pre", { class: "error" }, err.message));
|
|
1566
1745
|
return;
|
|
1567
1746
|
}
|
|
@@ -1585,7 +1764,7 @@ async function watchFinish() {
|
|
|
1585
1764
|
say(finishing());
|
|
1586
1765
|
await sleep(1000);
|
|
1587
1766
|
try {
|
|
1588
|
-
|
|
1767
|
+
track((await api("/api/finish-status")).job);
|
|
1589
1768
|
} catch (err) {
|
|
1590
1769
|
say(`Lost contact with the server (${err.message}); Finish goes on there. Retrying...`, true);
|
|
1591
1770
|
await sleep(2000);
|
|
@@ -1597,6 +1776,25 @@ async function watchFinish() {
|
|
|
1597
1776
|
await showTargets();
|
|
1598
1777
|
}
|
|
1599
1778
|
|
|
1779
|
+
// Takes the server's newest Finish. A server restarted during a Finish says it was interrupted;
|
|
1780
|
+
// one that could not save the Finish knows of none, so the page keeps the one it was following,
|
|
1781
|
+
// as interrupted, rather than showing the targets as if nothing had run.
|
|
1782
|
+
function track(job) {
|
|
1783
|
+
const last = state.job;
|
|
1784
|
+
if (!job && last?.running) {
|
|
1785
|
+
job = {
|
|
1786
|
+
...last,
|
|
1787
|
+
running: false,
|
|
1788
|
+
step: null,
|
|
1789
|
+
interrupted: true,
|
|
1790
|
+
error:
|
|
1791
|
+
`the server restarted while this Finish was at "${last.step}" and kept no record of it: check ` +
|
|
1792
|
+
`whether ${last.branch ?? "the visual/seed-* branch"} on origin has its commit before pressing Finish again`,
|
|
1793
|
+
};
|
|
1794
|
+
}
|
|
1795
|
+
state.job = !job && last?.interrupted ? last : job;
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1600
1798
|
// What the newest Finish did, shown above the targets until the server restarts.
|
|
1601
1799
|
function finishOutcome() {
|
|
1602
1800
|
const job = state.job;
|
|
@@ -1604,6 +1802,18 @@ function finishOutcome() {
|
|
|
1604
1802
|
return null;
|
|
1605
1803
|
}
|
|
1606
1804
|
const label = job.pr === null ? "the master seed" : `#${job.pr}`;
|
|
1805
|
+
if (job.interrupted) {
|
|
1806
|
+
return el(
|
|
1807
|
+
"section",
|
|
1808
|
+
{ class: "card finish-outcome" },
|
|
1809
|
+
el(
|
|
1810
|
+
"p",
|
|
1811
|
+
{ class: "error" },
|
|
1812
|
+
`Finish of ${label} was interrupted: the server restarted while it ran. Your decisions are kept.`,
|
|
1813
|
+
),
|
|
1814
|
+
el("pre", { class: "error" }, job.error),
|
|
1815
|
+
);
|
|
1816
|
+
}
|
|
1607
1817
|
if (job.error !== null) {
|
|
1608
1818
|
return el(
|
|
1609
1819
|
"section",
|
|
@@ -1638,7 +1848,7 @@ function finishOutcome() {
|
|
|
1638
1848
|
// ---------------------------------------------------------------- the address
|
|
1639
1849
|
|
|
1640
1850
|
// Every screen is in the address, after the session token, so a copied link opens it again:
|
|
1641
|
-
// #token=...&target=123&project=p&filter=undecided&q=text&item=file.png&view=side&zoom=fit&box=on&blink=off
|
|
1851
|
+
// #token=...&target=123&project=p&filter=undecided&q=text&item=file.png&view=side&zoom=fit&box=on&blink=off&flash=off
|
|
1642
1852
|
// Only the fragment holds it: a browser never sends a fragment to a server or in a Referer.
|
|
1643
1853
|
function hashFor() {
|
|
1644
1854
|
const p = new URLSearchParams({ token });
|
|
@@ -1656,6 +1866,7 @@ function hashFor() {
|
|
|
1656
1866
|
p.set("zoom", String(state.zoom));
|
|
1657
1867
|
p.set("box", state.showBox ? "on" : "off");
|
|
1658
1868
|
p.set("blink", state.blink ? "on" : "off");
|
|
1869
|
+
p.set("flash", state.spotFlash ? "on" : "off");
|
|
1659
1870
|
}
|
|
1660
1871
|
return `#${p}`;
|
|
1661
1872
|
}
|
|
@@ -1673,7 +1884,7 @@ function remember() {
|
|
|
1673
1884
|
screen !== state.screen ||
|
|
1674
1885
|
(screen !== "targets" &&
|
|
1675
1886
|
(was.get("target") !== String(state.target.id) || was.get("project") !== state.project));
|
|
1676
|
-
const push =
|
|
1887
|
+
const push = routes === 0 && moved;
|
|
1677
1888
|
history[push ? "pushState" : "replaceState"](null, "", hash);
|
|
1678
1889
|
}
|
|
1679
1890
|
|
|
@@ -1681,14 +1892,15 @@ function remember() {
|
|
|
1681
1892
|
// from a new CI run) lands on the nearest screen that does, with a line saying so.
|
|
1682
1893
|
async function route() {
|
|
1683
1894
|
const p = new URLSearchParams(location.hash.slice(1));
|
|
1684
|
-
|
|
1895
|
+
const seq = ++nav;
|
|
1896
|
+
routes++;
|
|
1685
1897
|
try {
|
|
1686
1898
|
const id = p.get("target");
|
|
1687
1899
|
if (!id) {
|
|
1688
1900
|
await showTargets();
|
|
1689
1901
|
return;
|
|
1690
1902
|
}
|
|
1691
|
-
if (!(await loadTargets())) {
|
|
1903
|
+
if (!(await loadTargets()) || seq !== nav) {
|
|
1692
1904
|
return;
|
|
1693
1905
|
}
|
|
1694
1906
|
const target = state.targets.find((t) => String(t.id) === id);
|
|
@@ -1700,12 +1912,18 @@ async function route() {
|
|
|
1700
1912
|
}
|
|
1701
1913
|
if (state.data === null || state.target?.id !== target.id || state.project !== project) {
|
|
1702
1914
|
state.sequence = [];
|
|
1915
|
+
state.data = null;
|
|
1703
1916
|
state.target = target;
|
|
1704
1917
|
state.project = project;
|
|
1705
1918
|
try {
|
|
1706
1919
|
await reload();
|
|
1707
1920
|
} catch (err) {
|
|
1708
|
-
|
|
1921
|
+
// A project whose download (or target) failed: its problem is on the targets screen.
|
|
1922
|
+
const problem = target.projects.find((x) => x.project === project).problem;
|
|
1923
|
+
await showTargets(`${project} of ${id} could not be opened: ${problem ?? err.message}`);
|
|
1924
|
+
return;
|
|
1925
|
+
}
|
|
1926
|
+
if (seq !== nav) {
|
|
1709
1927
|
return;
|
|
1710
1928
|
}
|
|
1711
1929
|
}
|
|
@@ -1732,18 +1950,21 @@ async function route() {
|
|
|
1732
1950
|
state.view = VIEWS.includes(p.get("view")) ? p.get("view") : "side";
|
|
1733
1951
|
const zoom = p.get("zoom") === "fit" ? "fit" : Number(p.get("zoom"));
|
|
1734
1952
|
state.zoom = ZOOMS.includes(zoom) ? zoom : "fit";
|
|
1735
|
-
// A link's box and
|
|
1953
|
+
// A link's box, blink and flash apply to this page; the browser's remembered choice is unchanged.
|
|
1736
1954
|
if (["on", "off"].includes(p.get("box"))) {
|
|
1737
1955
|
state.showBox = p.get("box") === "on";
|
|
1738
1956
|
}
|
|
1739
1957
|
if (["on", "off"].includes(p.get("blink"))) {
|
|
1740
1958
|
state.blink = p.get("blink") === "on";
|
|
1741
1959
|
}
|
|
1960
|
+
if (["on", "off"].includes(p.get("flash"))) {
|
|
1961
|
+
state.spotFlash = p.get("flash") === "on";
|
|
1962
|
+
}
|
|
1742
1963
|
state.box = 0;
|
|
1743
1964
|
say("");
|
|
1744
1965
|
showStory();
|
|
1745
1966
|
} finally {
|
|
1746
|
-
|
|
1967
|
+
routes--;
|
|
1747
1968
|
}
|
|
1748
1969
|
}
|
|
1749
1970
|
|
|
@@ -1768,7 +1989,9 @@ document.addEventListener("focusout", () => setTimeout(keepKeys));
|
|
|
1768
1989
|
keepKeys();
|
|
1769
1990
|
|
|
1770
1991
|
document.addEventListener("keydown", (e) => {
|
|
1771
|
-
|
|
1992
|
+
// An open question (ask) takes the keys: Escape cancels it.
|
|
1993
|
+
const asking = document.querySelector("dialog[open]") !== null;
|
|
1994
|
+
if (asking || !["story", "grid"].includes(state.screen) || e.ctrlKey || e.metaKey || e.altKey) {
|
|
1772
1995
|
return;
|
|
1773
1996
|
}
|
|
1774
1997
|
const inInput = e.target instanceof HTMLInputElement;
|
|
@@ -1815,7 +2038,8 @@ document.addEventListener("keydown", (e) => {
|
|
|
1815
2038
|
r: () => decide("reject"),
|
|
1816
2039
|
e: () => decide("exclude"),
|
|
1817
2040
|
u: () => decide(null),
|
|
1818
|
-
|
|
2041
|
+
// In Spotlight, F flashes the spotlighted baseline and new instead of leaving it.
|
|
2042
|
+
f: () => (state.view === "spotlight" ? toggleOption("spotFlash") : toggleView("flash")),
|
|
1819
2043
|
h: () => toggleView("highlight"),
|
|
1820
2044
|
s: () => toggleView("spotlight"),
|
|
1821
2045
|
b: () => toggleOption("showBox"),
|
|
@@ -1831,6 +2055,11 @@ document.addEventListener("keydown", (e) => {
|
|
|
1831
2055
|
},
|
|
1832
2056
|
};
|
|
1833
2057
|
const action = keys[key];
|
|
2058
|
+
if (e.repeat && ["a", "r", "e", "u"].includes(key)) {
|
|
2059
|
+
// A held decision key decides once, never the items after it unseen.
|
|
2060
|
+
e.preventDefault();
|
|
2061
|
+
return;
|
|
2062
|
+
}
|
|
1834
2063
|
if (action) {
|
|
1835
2064
|
e.preventDefault();
|
|
1836
2065
|
action();
|