@graphty/visual-review 0.1.3 → 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 +74 -32
- package/capture/capture.mjs +11 -1
- package/package.json +1 -1
- package/templates/visual-review.yml +4 -3
- package/trusted/cli.mjs +1 -0
- package/trusted/gate.mjs +68 -33
- package/trusted/lib/accept.mjs +67 -28
- package/trusted/lib/config.mjs +7 -3
- package/trusted/lib/github.mjs +100 -20
- package/trusted/lib/serve.mjs +445 -111
- package/trusted/page/review.css +19 -0
- package/trusted/page/review.js +341 -116
package/trusted/page/review.js
CHANGED
|
@@ -11,9 +11,10 @@ const app = document.getElementById("app");
|
|
|
11
11
|
const crumbs = document.getElementById("crumbs");
|
|
12
12
|
const statusLine = document.getElementById("status");
|
|
13
13
|
|
|
14
|
-
const REVIEWABLE = ["changed", "moved", "new", "removed", "unstable", "failed"];
|
|
15
|
-
const ACCEPTABLE = ["changed", "moved", "new", "removed"];
|
|
16
|
-
// A story with no baseline that this pull request did not change
|
|
14
|
+
const REVIEWABLE = ["changed", "moved", "new", "unseeded", "removed", "unstable", "failed"];
|
|
15
|
+
const ACCEPTABLE = ["changed", "moved", "new", "unseeded", "removed"];
|
|
16
|
+
// A story with no baseline that this pull request did not change. It blocks like `new`: accepting it
|
|
17
|
+
// creates its first baseline.
|
|
17
18
|
const UNSEEDED = "unseeded";
|
|
18
19
|
const NO_BASELINE = "no baseline yet";
|
|
19
20
|
const statusLabel = (status) => (status === UNSEEDED ? NO_BASELINE : status);
|
|
@@ -50,6 +51,7 @@ const state = {
|
|
|
50
51
|
box: 0, // which changed box "next changed box" is on
|
|
51
52
|
showBox: saved.showBox ?? true, // outline the changed box (B)
|
|
52
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)
|
|
53
55
|
held: null, // the view to return to when Space is released
|
|
54
56
|
pending: "reject", // what Enter in the reason box does
|
|
55
57
|
screen: "targets",
|
|
@@ -58,10 +60,22 @@ const state = {
|
|
|
58
60
|
};
|
|
59
61
|
const running = () => state.job?.running === true;
|
|
60
62
|
const VIEWS = ["side", "flash", "highlight", "spotlight"];
|
|
61
|
-
const FILTERS = ["undecided", "all", ...REVIEWABLE,
|
|
62
|
-
let
|
|
63
|
+
const FILTERS = ["undecided", "all", ...REVIEWABLE, ...Object.keys(DECISIONS)];
|
|
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.
|
|
63
68
|
const images = new Map();
|
|
69
|
+
const IMAGES_KEPT = 50;
|
|
64
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
|
|
65
79
|
let flashTimer = null;
|
|
66
80
|
let factor = 1; // CSS pixels per image pixel of the pictures on the stage
|
|
67
81
|
let shownBoxes = []; // the changed boxes of the item on the stage
|
|
@@ -102,6 +116,32 @@ function say(text, isError = false) {
|
|
|
102
116
|
statusLine.className = isError ? "error" : "";
|
|
103
117
|
}
|
|
104
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
|
+
|
|
105
145
|
async function api(path, body) {
|
|
106
146
|
const res = await fetch(path, {
|
|
107
147
|
method: body ? "POST" : "GET",
|
|
@@ -115,29 +155,55 @@ async function api(path, body) {
|
|
|
115
155
|
return json;
|
|
116
156
|
}
|
|
117
157
|
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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) => {
|
|
126
192
|
if (!res.ok) {
|
|
127
193
|
throw new Error(`${file}: ${(await res.json().catch(() => ({}))).error ?? res.status}`);
|
|
128
194
|
}
|
|
129
195
|
return URL.createObjectURL(await res.blob());
|
|
130
|
-
})
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
196
|
+
});
|
|
197
|
+
},
|
|
198
|
+
(url) => url.then(URL.revokeObjectURL, () => {}),
|
|
199
|
+
);
|
|
134
200
|
}
|
|
135
201
|
|
|
136
|
-
function loaded(url) {
|
|
202
|
+
function loaded(url, what) {
|
|
137
203
|
return new Promise((resolve, reject) => {
|
|
138
204
|
const img = new Image();
|
|
139
205
|
img.onload = () => resolve(img);
|
|
140
|
-
img.onerror = reject;
|
|
206
|
+
img.onerror = () => reject(new Error(`${what}: the image could not be shown`));
|
|
141
207
|
img.src = url;
|
|
142
208
|
});
|
|
143
209
|
}
|
|
@@ -148,6 +214,9 @@ const itemName = (item) => (item.mode ? `${item.id} (${item.mode})` : item.id);
|
|
|
148
214
|
const movedFrom = (item) => (item.from ? `moved from ${item.from}` : "");
|
|
149
215
|
const short = (sha) => (sha ? sha.slice(0, 10) : "none");
|
|
150
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;
|
|
151
220
|
const isLocal = () => state.target?.local === true;
|
|
152
221
|
// Device pixels per CSS pixel of this capture: 2 today, 1 for captures made before it was recorded.
|
|
153
222
|
const scale = () => state.data?.results.scale ?? 1;
|
|
@@ -182,18 +251,13 @@ function ordered(items) {
|
|
|
182
251
|
|
|
183
252
|
function visibleItems() {
|
|
184
253
|
const text = state.text.trim().toLowerCase();
|
|
185
|
-
let items;
|
|
186
|
-
if (state.filter ===
|
|
187
|
-
items =
|
|
188
|
-
} else {
|
|
189
|
-
items =
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
} else if (Object.hasOwn(DECISIONS, state.filter)) {
|
|
193
|
-
items = items.filter((i) => decisionOf(i)?.decision === state.filter);
|
|
194
|
-
} else if (state.filter !== "all") {
|
|
195
|
-
items = items.filter((i) => i.status === state.filter);
|
|
196
|
-
}
|
|
254
|
+
let items = state.data.items.filter((i) => REVIEWABLE.includes(i.status));
|
|
255
|
+
if (state.filter === "undecided") {
|
|
256
|
+
items = items.filter((i) => !decisionOf(i));
|
|
257
|
+
} else if (Object.hasOwn(DECISIONS, state.filter)) {
|
|
258
|
+
items = items.filter((i) => decisionOf(i)?.decision === state.filter);
|
|
259
|
+
} else if (state.filter !== "all") {
|
|
260
|
+
items = items.filter((i) => i.status === state.filter);
|
|
197
261
|
}
|
|
198
262
|
return ordered(text ? items.filter((i) => i.file.includes(text)) : items);
|
|
199
263
|
}
|
|
@@ -232,7 +296,10 @@ function loadOptions() {
|
|
|
232
296
|
|
|
233
297
|
function saveOptions() {
|
|
234
298
|
try {
|
|
235
|
-
localStorage.setItem(
|
|
299
|
+
localStorage.setItem(
|
|
300
|
+
OPTIONS_KEY,
|
|
301
|
+
JSON.stringify({ showBox: state.showBox, blink: state.blink, spotFlash: state.spotFlash }),
|
|
302
|
+
);
|
|
236
303
|
} catch {
|
|
237
304
|
// Not remembered; the choice still holds for this page.
|
|
238
305
|
}
|
|
@@ -251,7 +318,7 @@ async function loadTargets() {
|
|
|
251
318
|
try {
|
|
252
319
|
const [prs, status] = await Promise.all([api("/api/prs"), api("/api/finish-status")]);
|
|
253
320
|
state.targets = prs.targets;
|
|
254
|
-
|
|
321
|
+
track(status.job);
|
|
255
322
|
say("");
|
|
256
323
|
if (running()) {
|
|
257
324
|
// A reload during a Finish, on any screen: follow the running one, never offer a second.
|
|
@@ -265,10 +332,11 @@ async function loadTargets() {
|
|
|
265
332
|
}
|
|
266
333
|
|
|
267
334
|
async function showTargets(notice = "") {
|
|
335
|
+
const seq = ++nav;
|
|
268
336
|
stopFlash();
|
|
269
337
|
state.screen = "targets";
|
|
270
338
|
setCrumbs();
|
|
271
|
-
if (!(await loadTargets())) {
|
|
339
|
+
if (!(await loadTargets()) || seq !== nav) {
|
|
272
340
|
return;
|
|
273
341
|
}
|
|
274
342
|
remember();
|
|
@@ -384,7 +452,7 @@ function targetCard(t) {
|
|
|
384
452
|
el(
|
|
385
453
|
"td",
|
|
386
454
|
{},
|
|
387
|
-
p.reviewable > 0
|
|
455
|
+
p.reviewable > 0
|
|
388
456
|
? el("button", { type: "button", onclick: () => openProject(t, p.project) }, "Review")
|
|
389
457
|
: null,
|
|
390
458
|
),
|
|
@@ -404,7 +472,7 @@ function targetCard(t) {
|
|
|
404
472
|
type: "button",
|
|
405
473
|
class: "primary",
|
|
406
474
|
disabled: decided === 0 || running(),
|
|
407
|
-
onclick: () => finishTarget(t),
|
|
475
|
+
onclick: (e) => finishTarget(t, e.currentTarget),
|
|
408
476
|
},
|
|
409
477
|
`Finish ${t.pr === null ? "seed" : `#${t.pr}`} (${decided} decisions)`,
|
|
410
478
|
),
|
|
@@ -415,6 +483,7 @@ function targetCard(t) {
|
|
|
415
483
|
// ---------------------------------------------------------------- screen: project grid
|
|
416
484
|
|
|
417
485
|
async function openProject(target, project) {
|
|
486
|
+
const seq = ++nav;
|
|
418
487
|
state.target = target;
|
|
419
488
|
state.project = project;
|
|
420
489
|
state.filter = "undecided";
|
|
@@ -429,6 +498,9 @@ async function openProject(target, project) {
|
|
|
429
498
|
say(err.message, true);
|
|
430
499
|
return;
|
|
431
500
|
}
|
|
501
|
+
if (seq !== nav) {
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
432
504
|
// Nothing left to decide (or a local preview): show everything instead of an empty grid.
|
|
433
505
|
if (visibleItems().length === 0) {
|
|
434
506
|
state.filter = "all";
|
|
@@ -448,8 +520,8 @@ const thumbs = new IntersectionObserver((entries) => {
|
|
|
448
520
|
for (const e of entries) {
|
|
449
521
|
if (e.isIntersecting) {
|
|
450
522
|
thumbs.unobserve(e.target);
|
|
451
|
-
const { kind, file } = e.target.dataset;
|
|
452
|
-
image(kind, file).then(
|
|
523
|
+
const { kind, file, hash } = e.target.dataset;
|
|
524
|
+
image(kind, file, hash).then(
|
|
453
525
|
(url) => (e.target.src = url),
|
|
454
526
|
(err) => (e.target.alt = err.message),
|
|
455
527
|
);
|
|
@@ -470,7 +542,7 @@ function openItem(index) {
|
|
|
470
542
|
function tile(item, number) {
|
|
471
543
|
const kind = item.capture ? "capture" : item.baseline ? "baseline" : null;
|
|
472
544
|
const img = kind
|
|
473
|
-
? 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) })
|
|
474
546
|
: el("div", { class: "noimage" }, item.status);
|
|
475
547
|
if (kind) {
|
|
476
548
|
thumbs.observe(img);
|
|
@@ -547,11 +619,12 @@ function errorRow(item, number) {
|
|
|
547
619
|
);
|
|
548
620
|
}
|
|
549
621
|
|
|
550
|
-
//
|
|
551
|
-
const
|
|
622
|
+
// The items of `component` Accept would take without opening them.
|
|
623
|
+
const undecided = (component) =>
|
|
552
624
|
state.data.items.filter(
|
|
553
625
|
(i) => ACCEPTABLE.includes(i.status) && !decisionOf(i) && (!component || componentOf(i.id) === component),
|
|
554
|
-
)
|
|
626
|
+
);
|
|
627
|
+
const undecidedIn = (component) => undecided(component).length;
|
|
555
628
|
|
|
556
629
|
// The files whose decisions a bulk Undo in `component` (or the whole project) clears: every
|
|
557
630
|
// decision not yet posted by Finish.
|
|
@@ -625,7 +698,9 @@ async function undo(files, where, one = false) {
|
|
|
625
698
|
|
|
626
699
|
function showGrid() {
|
|
627
700
|
stopFlash();
|
|
701
|
+
thumbs.disconnect();
|
|
628
702
|
state.screen = "grid";
|
|
703
|
+
state.pending = "reject";
|
|
629
704
|
setCrumbs(el("span", {}, targetLabel()), el("span", {}, state.project));
|
|
630
705
|
const items = visibleItems();
|
|
631
706
|
const counts = {};
|
|
@@ -851,20 +926,34 @@ function showStory() {
|
|
|
851
926
|
}
|
|
852
927
|
state.index = Math.max(0, Math.min(state.index, items.length - 1));
|
|
853
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
|
+
}
|
|
854
934
|
state.lastFile = item.file;
|
|
855
935
|
const d = decisionOf(item);
|
|
856
936
|
if (d?.bulk && !isLocal()) {
|
|
857
|
-
// 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.
|
|
858
940
|
delete d.bulk;
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
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));
|
|
862
951
|
}
|
|
863
952
|
const note = singleImageNote(item);
|
|
864
953
|
const view = note ? "side" : state.view;
|
|
865
954
|
const onlyExclude = item.status === "unstable" || item.status === "failed";
|
|
866
955
|
const unseeded = item.status === UNSEEDED;
|
|
867
|
-
const decidable = !isLocal()
|
|
956
|
+
const decidable = !isLocal();
|
|
868
957
|
setCrumbs(
|
|
869
958
|
el("button", { type: "button", class: "link", onclick: showGrid }, `${targetLabel()} / ${state.project}`),
|
|
870
959
|
el("span", {}, itemName(item)),
|
|
@@ -905,11 +994,11 @@ function showStory() {
|
|
|
905
994
|
const reason = el("input", {
|
|
906
995
|
id: "reason",
|
|
907
996
|
type: "text",
|
|
908
|
-
placeholder:
|
|
997
|
+
placeholder: reasonHint(),
|
|
909
998
|
value: d?.reason ?? "",
|
|
910
999
|
maxlength: "2000",
|
|
911
1000
|
onkeydown: (e) => {
|
|
912
|
-
if (e.key === "Enter") {
|
|
1001
|
+
if (e.key === "Enter" && !e.repeat) {
|
|
913
1002
|
decide(state.pending);
|
|
914
1003
|
}
|
|
915
1004
|
},
|
|
@@ -923,14 +1012,21 @@ function showStory() {
|
|
|
923
1012
|
state.data.acceptable && !onlyExclude
|
|
924
1013
|
? el(
|
|
925
1014
|
"button",
|
|
926
|
-
{
|
|
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
|
+
},
|
|
927
1023
|
"Accept",
|
|
928
1024
|
)
|
|
929
1025
|
: null,
|
|
930
1026
|
!onlyExclude
|
|
931
1027
|
? el(
|
|
932
1028
|
"button",
|
|
933
|
-
{ type: "button", class: "reject", onclick: () =>
|
|
1029
|
+
{ type: "button", class: "reject", onclick: () => clicked("reject"), title: "R" },
|
|
934
1030
|
"Reject",
|
|
935
1031
|
)
|
|
936
1032
|
: null,
|
|
@@ -939,7 +1035,7 @@ function showStory() {
|
|
|
939
1035
|
"button",
|
|
940
1036
|
{
|
|
941
1037
|
type: "button",
|
|
942
|
-
onclick: () =>
|
|
1038
|
+
onclick: () => clicked("exclude"),
|
|
943
1039
|
title: "E: stop capturing every mode of this story",
|
|
944
1040
|
},
|
|
945
1041
|
"Exclude",
|
|
@@ -1025,6 +1121,20 @@ function showStory() {
|
|
|
1025
1121
|
},
|
|
1026
1122
|
"Blink",
|
|
1027
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
|
+
),
|
|
1028
1138
|
note ? el("span", { id: "single-note", class: "meta" }, note) : null,
|
|
1029
1139
|
el("span", { class: "spacer" }),
|
|
1030
1140
|
ZOOMS.map(zoomButton),
|
|
@@ -1069,7 +1179,7 @@ function showStory() {
|
|
|
1069
1179
|
"p",
|
|
1070
1180
|
{},
|
|
1071
1181
|
"No baseline yet, and this pull request does not change it: it looks as on master. " +
|
|
1072
|
-
"
|
|
1182
|
+
"Accepting it makes this image its first baseline.",
|
|
1073
1183
|
)
|
|
1074
1184
|
: null,
|
|
1075
1185
|
decidable ? decisionButtons : null,
|
|
@@ -1080,37 +1190,35 @@ function showStory() {
|
|
|
1080
1190
|
remember();
|
|
1081
1191
|
}
|
|
1082
1192
|
|
|
1193
|
+
const reasonHint = () => `Reason (needed to reject or exclude); Enter ${state.pending}s`;
|
|
1194
|
+
|
|
1083
1195
|
// The changed pixels of an item, padded top-left to the larger size, grown by GROW pixels, and the
|
|
1084
|
-
// 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.
|
|
1085
1197
|
async function diffOf(item) {
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
})(),
|
|
1111
|
-
);
|
|
1112
|
-
}
|
|
1113
|
-
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
|
+
});
|
|
1114
1222
|
}
|
|
1115
1223
|
|
|
1116
1224
|
// A square dilation by GROW pixels, as two passes (rows, then columns) of a sliding window.
|
|
@@ -1212,6 +1320,7 @@ function fit(stage) {
|
|
|
1212
1320
|
// Two panes, always: the baseline on the left and the new image (or the view's picture) on the
|
|
1213
1321
|
// right. A missing image leaves its pane empty, the same size, so the other one never moves.
|
|
1214
1322
|
async function renderStage(item, view, keep) {
|
|
1323
|
+
const seq = ++stageRender;
|
|
1215
1324
|
const stage = document.getElementById("stage");
|
|
1216
1325
|
const pane = (text, ...pics) =>
|
|
1217
1326
|
el(
|
|
@@ -1225,7 +1334,7 @@ async function renderStage(item, view, keep) {
|
|
|
1225
1334
|
),
|
|
1226
1335
|
);
|
|
1227
1336
|
const imgOf = async (kind) => {
|
|
1228
|
-
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}`);
|
|
1229
1338
|
img.alt = `${kind} of ${itemName(item)}`;
|
|
1230
1339
|
return img;
|
|
1231
1340
|
};
|
|
@@ -1245,11 +1354,18 @@ async function renderStage(item, view, keep) {
|
|
|
1245
1354
|
right = pane(item.status === "failed" ? "No capture: it failed" : "No capture");
|
|
1246
1355
|
} else if (view === "side") {
|
|
1247
1356
|
right = pane(item.baseline ? "New" : "New (no baseline)", await imgOf("capture"));
|
|
1248
|
-
} else if (view === "flash") {
|
|
1249
|
-
// The two images
|
|
1250
|
-
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
|
+
}
|
|
1251
1367
|
next.style.visibility = "hidden";
|
|
1252
|
-
right = pane(
|
|
1368
|
+
right = pane(`${name}: baseline`, base, next);
|
|
1253
1369
|
right.classList.add("flashing");
|
|
1254
1370
|
const tag = right.querySelector(".label");
|
|
1255
1371
|
let showingNew = false;
|
|
@@ -1257,14 +1373,23 @@ async function renderStage(item, view, keep) {
|
|
|
1257
1373
|
showingNew = !showingNew;
|
|
1258
1374
|
base.style.visibility = showingNew ? "hidden" : "visible";
|
|
1259
1375
|
next.style.visibility = showingNew ? "visible" : "hidden";
|
|
1260
|
-
tag.textContent = showingNew ?
|
|
1376
|
+
tag.textContent = showingNew ? `${name}: new` : `${name}: baseline`;
|
|
1261
1377
|
}, FLASH_MS);
|
|
1262
1378
|
} else if (view === "highlight") {
|
|
1263
1379
|
right = pane("New, changed pixels in red", await imgOf("capture"), overlay(diff));
|
|
1264
1380
|
} else {
|
|
1265
1381
|
right = pane("Spotlight: the new image, dimmed except around each change", spotlight(diff));
|
|
1266
1382
|
}
|
|
1383
|
+
if (seq !== stageRender) {
|
|
1384
|
+
// Another item (or view) was shown while this one loaded.
|
|
1385
|
+
return;
|
|
1386
|
+
}
|
|
1267
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
|
+
}
|
|
1268
1393
|
if (marked && state.blink) {
|
|
1269
1394
|
// Both panes' overlays on and off together, at Flash's pace.
|
|
1270
1395
|
const marks = [...stage.querySelectorAll(".diffmark")];
|
|
@@ -1307,7 +1432,9 @@ async function renderStage(item, view, keep) {
|
|
|
1307
1432
|
}
|
|
1308
1433
|
}
|
|
1309
1434
|
} catch (err) {
|
|
1310
|
-
|
|
1435
|
+
if (seq === stageRender) {
|
|
1436
|
+
stage.replaceChildren(el("p", { class: "error" }, err.message || "the images could not be loaded"));
|
|
1437
|
+
}
|
|
1311
1438
|
}
|
|
1312
1439
|
}
|
|
1313
1440
|
|
|
@@ -1392,8 +1519,8 @@ function overlay(diff) {
|
|
|
1392
1519
|
return canvas;
|
|
1393
1520
|
}
|
|
1394
1521
|
|
|
1395
|
-
//
|
|
1396
|
-
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) {
|
|
1397
1524
|
const canvas = el("canvas", { width: String(diff.w), height: String(diff.h) });
|
|
1398
1525
|
const ctx = canvas.getContext("2d");
|
|
1399
1526
|
const out = ctx.createImageData(diff.w, diff.h);
|
|
@@ -1401,9 +1528,9 @@ function spotlight(diff) {
|
|
|
1401
1528
|
for (let i = 0; i < diff.w * diff.h; i++) {
|
|
1402
1529
|
const lit = diff.grown[i] === 1;
|
|
1403
1530
|
for (let c = 0; c < 3; c++) {
|
|
1404
|
-
out.data[i * 4 + c] = lit ?
|
|
1531
|
+
out.data[i * 4 + c] = lit ? px[i * 4 + c] : px[i * 4 + c] * keep;
|
|
1405
1532
|
}
|
|
1406
|
-
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);
|
|
1407
1534
|
}
|
|
1408
1535
|
ctx.putImageData(out, 0, 0);
|
|
1409
1536
|
return canvas;
|
|
@@ -1414,19 +1541,28 @@ async function acceptAll(component) {
|
|
|
1414
1541
|
say(`${state.project} cannot be accepted here`, true);
|
|
1415
1542
|
return;
|
|
1416
1543
|
}
|
|
1417
|
-
const
|
|
1544
|
+
const items = undecided(component);
|
|
1545
|
+
const n = items.length;
|
|
1418
1546
|
const where = component ? `the component ${component}` : state.project;
|
|
1419
1547
|
if (n === 0) {
|
|
1420
1548
|
say(`Nothing undecided to accept in ${where}.`);
|
|
1421
1549
|
return;
|
|
1422
1550
|
}
|
|
1423
|
-
|
|
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"))) {
|
|
1424
1558
|
return;
|
|
1425
1559
|
}
|
|
1426
1560
|
try {
|
|
1427
1561
|
await api("/api/accept-all", {
|
|
1428
1562
|
id: state.target.id,
|
|
1429
1563
|
project: state.project,
|
|
1564
|
+
runId: state.data.target.runId,
|
|
1565
|
+
runAttempt: state.data.target.runAttempt,
|
|
1430
1566
|
...(component ? { component } : {}),
|
|
1431
1567
|
});
|
|
1432
1568
|
await reload();
|
|
@@ -1451,6 +1587,10 @@ async function decide(decision) {
|
|
|
1451
1587
|
say("A local preview is only looked at: nothing is decided on it.", true);
|
|
1452
1588
|
return;
|
|
1453
1589
|
}
|
|
1590
|
+
if (deciding) {
|
|
1591
|
+
// A second click or key while the first decision is on its way.
|
|
1592
|
+
return;
|
|
1593
|
+
}
|
|
1454
1594
|
const items = passItems();
|
|
1455
1595
|
const item = items[state.index];
|
|
1456
1596
|
const before = decisionOf(item);
|
|
@@ -1463,23 +1603,32 @@ async function decide(decision) {
|
|
|
1463
1603
|
say(`${itemName(item)} is already ${done}. Press U (Undo) first to change it.`, true);
|
|
1464
1604
|
return;
|
|
1465
1605
|
}
|
|
1606
|
+
if (decision === "accept" && stageShown !== item.file) {
|
|
1607
|
+
say(`${itemName(item)}: wait for both images before accepting.`, true);
|
|
1608
|
+
return;
|
|
1609
|
+
}
|
|
1466
1610
|
const reasonBox = document.getElementById("reason");
|
|
1467
1611
|
const reason = reasonBox?.value.trim() ?? "";
|
|
1468
1612
|
if ((decision === "reject" || decision === "exclude") && reason === "") {
|
|
1469
1613
|
state.pending = decision;
|
|
1470
|
-
reasonBox
|
|
1614
|
+
if (reasonBox) {
|
|
1615
|
+
reasonBox.placeholder = reasonHint();
|
|
1616
|
+
reasonBox.focus();
|
|
1617
|
+
}
|
|
1471
1618
|
say(`A ${decision} needs a reason: type it, then press Enter.`);
|
|
1472
1619
|
return;
|
|
1473
1620
|
}
|
|
1474
1621
|
if (
|
|
1475
1622
|
decision === "exclude" &&
|
|
1476
|
-
!
|
|
1623
|
+
!(await ask(
|
|
1477
1624
|
`Exclude ${item.id}? Every mode of this story stops being captured, on every pull request, ` +
|
|
1478
1625
|
"until its settings file is removed. To clear a one-off failure, re-run the visual job instead.",
|
|
1479
|
-
|
|
1626
|
+
"Exclude",
|
|
1627
|
+
))
|
|
1480
1628
|
) {
|
|
1481
1629
|
return;
|
|
1482
1630
|
}
|
|
1631
|
+
deciding = true;
|
|
1483
1632
|
try {
|
|
1484
1633
|
await api("/api/decide", {
|
|
1485
1634
|
id: state.target.id,
|
|
@@ -1487,10 +1636,13 @@ async function decide(decision) {
|
|
|
1487
1636
|
file: item.file,
|
|
1488
1637
|
decision,
|
|
1489
1638
|
reason: reason === "" ? null : reason,
|
|
1639
|
+
hash: imageHash(item),
|
|
1490
1640
|
});
|
|
1491
1641
|
} catch (err) {
|
|
1492
1642
|
say(err.message, true);
|
|
1493
1643
|
return;
|
|
1644
|
+
} finally {
|
|
1645
|
+
deciding = false;
|
|
1494
1646
|
}
|
|
1495
1647
|
if (decision === null) {
|
|
1496
1648
|
delete state.data.decisions[item.file];
|
|
@@ -1508,6 +1660,15 @@ async function decide(decision) {
|
|
|
1508
1660
|
showStory();
|
|
1509
1661
|
}
|
|
1510
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
|
+
|
|
1511
1672
|
// ---------------------------------------------------------------- Finish
|
|
1512
1673
|
|
|
1513
1674
|
function finishButton() {
|
|
@@ -1516,24 +1677,36 @@ function finishButton() {
|
|
|
1516
1677
|
}
|
|
1517
1678
|
return el(
|
|
1518
1679
|
"button",
|
|
1519
|
-
{
|
|
1680
|
+
{
|
|
1681
|
+
type: "button",
|
|
1682
|
+
class: "primary",
|
|
1683
|
+
disabled: running(),
|
|
1684
|
+
onclick: (e) => finishTarget(state.target, e.currentTarget),
|
|
1685
|
+
},
|
|
1520
1686
|
`Finish ${targetLabel()}`,
|
|
1521
1687
|
);
|
|
1522
1688
|
}
|
|
1523
1689
|
|
|
1524
|
-
|
|
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}`;
|
|
1525
1694
|
const what =
|
|
1526
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...`);
|
|
1527
1702
|
let fresh;
|
|
1528
1703
|
try {
|
|
1529
1704
|
fresh = await api(`/api/target/${encodeURIComponent(target.id)}`);
|
|
1530
1705
|
} catch (err) {
|
|
1531
|
-
|
|
1706
|
+
giveUp(`Finish not started: ${err.message}`, true);
|
|
1532
1707
|
return;
|
|
1533
1708
|
}
|
|
1534
|
-
const lines = [
|
|
1535
|
-
`Finish ${target.pr === null ? "the master seed" : `#${target.pr}`}: ${what}, across every project?`,
|
|
1536
|
-
];
|
|
1709
|
+
const lines = [`Finish ${label}: ${what}, across every project?`];
|
|
1537
1710
|
const notOpened = fresh.projects.reduce((n, p) => n + p.notOpened, 0);
|
|
1538
1711
|
if (notOpened > 0) {
|
|
1539
1712
|
lines.push(`${notOpened} accepted without being opened.`);
|
|
@@ -1551,7 +1724,9 @@ async function finishTarget(target) {
|
|
|
1551
1724
|
);
|
|
1552
1725
|
}
|
|
1553
1726
|
lines.push("One commit status is posted when Finish completes.");
|
|
1554
|
-
|
|
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.");
|
|
1555
1730
|
return;
|
|
1556
1731
|
}
|
|
1557
1732
|
// Finish runs on the server and can take minutes; the page only starts it and then asks how it
|
|
@@ -1565,7 +1740,7 @@ async function finishTarget(target) {
|
|
|
1565
1740
|
.then((s) => s.job)
|
|
1566
1741
|
.catch(() => null);
|
|
1567
1742
|
if (!running() || state.job.target !== target.id) {
|
|
1568
|
-
|
|
1743
|
+
giveUp("Finish failed; your decisions are kept. Fix the cause and press Finish again.", true);
|
|
1569
1744
|
app.prepend(el("pre", { class: "error" }, err.message));
|
|
1570
1745
|
return;
|
|
1571
1746
|
}
|
|
@@ -1589,7 +1764,7 @@ async function watchFinish() {
|
|
|
1589
1764
|
say(finishing());
|
|
1590
1765
|
await sleep(1000);
|
|
1591
1766
|
try {
|
|
1592
|
-
|
|
1767
|
+
track((await api("/api/finish-status")).job);
|
|
1593
1768
|
} catch (err) {
|
|
1594
1769
|
say(`Lost contact with the server (${err.message}); Finish goes on there. Retrying...`, true);
|
|
1595
1770
|
await sleep(2000);
|
|
@@ -1601,6 +1776,25 @@ async function watchFinish() {
|
|
|
1601
1776
|
await showTargets();
|
|
1602
1777
|
}
|
|
1603
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
|
+
|
|
1604
1798
|
// What the newest Finish did, shown above the targets until the server restarts.
|
|
1605
1799
|
function finishOutcome() {
|
|
1606
1800
|
const job = state.job;
|
|
@@ -1608,6 +1802,18 @@ function finishOutcome() {
|
|
|
1608
1802
|
return null;
|
|
1609
1803
|
}
|
|
1610
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
|
+
}
|
|
1611
1817
|
if (job.error !== null) {
|
|
1612
1818
|
return el(
|
|
1613
1819
|
"section",
|
|
@@ -1642,7 +1848,7 @@ function finishOutcome() {
|
|
|
1642
1848
|
// ---------------------------------------------------------------- the address
|
|
1643
1849
|
|
|
1644
1850
|
// Every screen is in the address, after the session token, so a copied link opens it again:
|
|
1645
|
-
// #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
|
|
1646
1852
|
// Only the fragment holds it: a browser never sends a fragment to a server or in a Referer.
|
|
1647
1853
|
function hashFor() {
|
|
1648
1854
|
const p = new URLSearchParams({ token });
|
|
@@ -1660,6 +1866,7 @@ function hashFor() {
|
|
|
1660
1866
|
p.set("zoom", String(state.zoom));
|
|
1661
1867
|
p.set("box", state.showBox ? "on" : "off");
|
|
1662
1868
|
p.set("blink", state.blink ? "on" : "off");
|
|
1869
|
+
p.set("flash", state.spotFlash ? "on" : "off");
|
|
1663
1870
|
}
|
|
1664
1871
|
return `#${p}`;
|
|
1665
1872
|
}
|
|
@@ -1677,7 +1884,7 @@ function remember() {
|
|
|
1677
1884
|
screen !== state.screen ||
|
|
1678
1885
|
(screen !== "targets" &&
|
|
1679
1886
|
(was.get("target") !== String(state.target.id) || was.get("project") !== state.project));
|
|
1680
|
-
const push =
|
|
1887
|
+
const push = routes === 0 && moved;
|
|
1681
1888
|
history[push ? "pushState" : "replaceState"](null, "", hash);
|
|
1682
1889
|
}
|
|
1683
1890
|
|
|
@@ -1685,14 +1892,15 @@ function remember() {
|
|
|
1685
1892
|
// from a new CI run) lands on the nearest screen that does, with a line saying so.
|
|
1686
1893
|
async function route() {
|
|
1687
1894
|
const p = new URLSearchParams(location.hash.slice(1));
|
|
1688
|
-
|
|
1895
|
+
const seq = ++nav;
|
|
1896
|
+
routes++;
|
|
1689
1897
|
try {
|
|
1690
1898
|
const id = p.get("target");
|
|
1691
1899
|
if (!id) {
|
|
1692
1900
|
await showTargets();
|
|
1693
1901
|
return;
|
|
1694
1902
|
}
|
|
1695
|
-
if (!(await loadTargets())) {
|
|
1903
|
+
if (!(await loadTargets()) || seq !== nav) {
|
|
1696
1904
|
return;
|
|
1697
1905
|
}
|
|
1698
1906
|
const target = state.targets.find((t) => String(t.id) === id);
|
|
@@ -1704,12 +1912,18 @@ async function route() {
|
|
|
1704
1912
|
}
|
|
1705
1913
|
if (state.data === null || state.target?.id !== target.id || state.project !== project) {
|
|
1706
1914
|
state.sequence = [];
|
|
1915
|
+
state.data = null;
|
|
1707
1916
|
state.target = target;
|
|
1708
1917
|
state.project = project;
|
|
1709
1918
|
try {
|
|
1710
1919
|
await reload();
|
|
1711
1920
|
} catch (err) {
|
|
1712
|
-
|
|
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) {
|
|
1713
1927
|
return;
|
|
1714
1928
|
}
|
|
1715
1929
|
}
|
|
@@ -1736,18 +1950,21 @@ async function route() {
|
|
|
1736
1950
|
state.view = VIEWS.includes(p.get("view")) ? p.get("view") : "side";
|
|
1737
1951
|
const zoom = p.get("zoom") === "fit" ? "fit" : Number(p.get("zoom"));
|
|
1738
1952
|
state.zoom = ZOOMS.includes(zoom) ? zoom : "fit";
|
|
1739
|
-
// A link's box and
|
|
1953
|
+
// A link's box, blink and flash apply to this page; the browser's remembered choice is unchanged.
|
|
1740
1954
|
if (["on", "off"].includes(p.get("box"))) {
|
|
1741
1955
|
state.showBox = p.get("box") === "on";
|
|
1742
1956
|
}
|
|
1743
1957
|
if (["on", "off"].includes(p.get("blink"))) {
|
|
1744
1958
|
state.blink = p.get("blink") === "on";
|
|
1745
1959
|
}
|
|
1960
|
+
if (["on", "off"].includes(p.get("flash"))) {
|
|
1961
|
+
state.spotFlash = p.get("flash") === "on";
|
|
1962
|
+
}
|
|
1746
1963
|
state.box = 0;
|
|
1747
1964
|
say("");
|
|
1748
1965
|
showStory();
|
|
1749
1966
|
} finally {
|
|
1750
|
-
|
|
1967
|
+
routes--;
|
|
1751
1968
|
}
|
|
1752
1969
|
}
|
|
1753
1970
|
|
|
@@ -1772,7 +1989,9 @@ document.addEventListener("focusout", () => setTimeout(keepKeys));
|
|
|
1772
1989
|
keepKeys();
|
|
1773
1990
|
|
|
1774
1991
|
document.addEventListener("keydown", (e) => {
|
|
1775
|
-
|
|
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) {
|
|
1776
1995
|
return;
|
|
1777
1996
|
}
|
|
1778
1997
|
const inInput = e.target instanceof HTMLInputElement;
|
|
@@ -1819,7 +2038,8 @@ document.addEventListener("keydown", (e) => {
|
|
|
1819
2038
|
r: () => decide("reject"),
|
|
1820
2039
|
e: () => decide("exclude"),
|
|
1821
2040
|
u: () => decide(null),
|
|
1822
|
-
|
|
2041
|
+
// In Spotlight, F flashes the spotlighted baseline and new instead of leaving it.
|
|
2042
|
+
f: () => (state.view === "spotlight" ? toggleOption("spotFlash") : toggleView("flash")),
|
|
1823
2043
|
h: () => toggleView("highlight"),
|
|
1824
2044
|
s: () => toggleView("spotlight"),
|
|
1825
2045
|
b: () => toggleOption("showBox"),
|
|
@@ -1835,6 +2055,11 @@ document.addEventListener("keydown", (e) => {
|
|
|
1835
2055
|
},
|
|
1836
2056
|
};
|
|
1837
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
|
+
}
|
|
1838
2063
|
if (action) {
|
|
1839
2064
|
e.preventDefault();
|
|
1840
2065
|
action();
|