proctolog 0.1.0 → 0.2.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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +87 -3
  3. data/README.md +15 -4
  4. data/app/controllers/proctolog/application_controller.rb +1 -1
  5. data/app/controllers/proctolog/bursts_controller.rb +1 -1
  6. data/app/controllers/proctolog/choices_controller.rb +3 -1
  7. data/app/controllers/proctolog/decisions_controller.rb +1 -1
  8. data/app/controllers/proctolog/notes_controller.rb +2 -2
  9. data/app/controllers/proctolog/prompts_controller.rb +1 -1
  10. data/app/controllers/proctolog/pull_requests_controller.rb +2 -4
  11. data/app/controllers/proctolog/review_data_controller.rb +3 -1
  12. data/app/controllers/proctolog/shares_controller.rb +27 -0
  13. data/app/helpers/proctolog/decisions_helper.rb +6 -2
  14. data/app/helpers/proctolog/desk_helper.rb +48 -8
  15. data/app/helpers/proctolog/rounds_helper.rb +4 -2
  16. data/app/helpers/proctolog/shares_helper.rb +11 -0
  17. data/app/helpers/proctolog/standings_helper.rb +5 -4
  18. data/app/models/proctolog/burst.rb +17 -8
  19. data/app/models/proctolog/choices.rb +8 -0
  20. data/app/models/proctolog/doctor.rb +9 -1
  21. data/app/models/proctolog/github.rb +1 -1
  22. data/app/models/proctolog/marks.rb +1 -1
  23. data/app/models/proctolog/note.rb +4 -0
  24. data/app/models/proctolog/owner.rb +3 -0
  25. data/app/models/proctolog/pick.rb +12 -0
  26. data/app/models/proctolog/rounds.rb +37 -0
  27. data/app/models/proctolog/share/ngrok.rb +120 -0
  28. data/app/models/proctolog/share/settings.rb +23 -0
  29. data/app/models/proctolog/share/tailscale.rb +103 -0
  30. data/app/models/proctolog/share.rb +101 -0
  31. data/app/models/proctolog/standing.rb +24 -7
  32. data/app/views/proctolog/bursts/_line.html.erb +2 -2
  33. data/app/views/proctolog/bursts/_side.html.erb +6 -5
  34. data/app/views/proctolog/bursts/_side_stop.html.erb +4 -4
  35. data/app/views/proctolog/notes/_card.html.erb +7 -3
  36. data/app/views/proctolog/open_pull_requests/_side.html.erb +4 -3
  37. data/app/views/proctolog/open_pull_requests/_start.html.erb +8 -0
  38. data/app/views/proctolog/open_pull_requests/index.html.erb +4 -5
  39. data/app/views/proctolog/open_pull_requests/start/_first_time.html.erb +3 -0
  40. data/app/views/proctolog/open_pull_requests/start/_nothing_open.html.erb +3 -0
  41. data/app/views/proctolog/open_pull_requests/start/_nothing_written.html.erb +3 -0
  42. data/app/views/proctolog/shares/_line.html.erb +5 -0
  43. data/app/views/proctolog/shares/show.html.erb +48 -0
  44. data/app/views/proctolog/shares/ways/_ngrok.html.erb +6 -0
  45. data/app/views/proctolog/shares/ways/_tailscale.html.erb +2 -0
  46. data/bin/proctolog +92 -6
  47. data/completions/_proctolog +28 -4
  48. data/config/application.rb +23 -4
  49. data/config/initializers/grouse.rb +1 -1
  50. data/config/routes.rb +3 -0
  51. data/docs/manual/bursts.md +7 -2
  52. data/docs/manual/deciding.md +4 -1
  53. data/docs/manual/handover.md +6 -0
  54. data/docs/manual/reading.md +54 -27
  55. data/docs/manual/sharing.md +77 -0
  56. data/lib/proctolog/changelog.rb +50 -0
  57. data/lib/proctolog/version.rb +1 -1
  58. data/public/asking.js +31 -4
  59. data/public/decisions.js +4 -4
  60. data/public/desk.js +10 -6
  61. data/public/keys.js +6 -0
  62. data/public/notes.js +164 -58
  63. data/public/pick.css +21 -0
  64. data/public/respond.css +6 -0
  65. data/public/respond.js +51 -7
  66. data/public/review-drafts.js +47 -9
  67. data/public/review-rounds.js +27 -3
  68. data/public/share.css +58 -0
  69. data/public/side.js +11 -20
  70. data/public/terminal.css +451 -77
  71. data/public/viewer.js +700 -173
  72. metadata +18 -1
data/public/viewer.js CHANGED
@@ -6,27 +6,66 @@
6
6
  // opened the viewer writes its form beside it, and the viewer stays as it was,
7
7
  // zoom and all, for the next. The notes the picture has are drawn on it, and
8
8
  // a click on one opens it there the same way, to be written over or
9
- // withdrawn. [ and ] go through the pictures it was opened with.
9
+ // withdrawn. [ and ] go through the pictures it was opened with. Once the
10
+ // stage no longer shows the whole of what is looked at, a map in its corner
11
+ // says where it is, and a press there goes to that place.
10
12
  //
11
- // A pair, the same screen before a change and after it, is looked at five
12
- // ways: side by side; swipe, the after picture uncovered over the before up
13
- // to a divider; blink, one and then the other; onion skin, the after picture
14
- // seen through over the before; and difference, where only what changed is
15
- // lit. n goes from one changed region to the next. A mark is made side by
16
- // side, on the half it is made on, and in blink, on the one showing.
13
+ // A pair, the same screen before a change and after it, has a panel beside
14
+ // the stage, quiet unless asked: the five ways a pair is looked at, each a
15
+ // button with its key, the one it is looked at in saying what it shows. A
16
+ // view's key again goes back to side by side; blink's shows the other half,
17
+ // and Shift B sets it blinking by itself, or stops it. Under them, the
18
+ // regions where the halves differ, outlined dashed on the picture as a note
19
+ // is drawn solid. n and p focus on the next and the one before, which the
20
+ // panel shows as each half has it, a press on either, or Enter, writing a
21
+ // note there. Focusing keeps the zoom the owner chose: seen whole, the
22
+ // region is only outlined; zoomed in, the stage goes near enough to see it
23
+ // whole, as z does. A mark is made side by side, on the half it is made on,
24
+ // and in blink on the one showing, which then holds still.
25
+
26
+ import { keyOf } from "./keys.js";
17
27
 
18
28
  const NEAREST = 12;
19
29
  const DRAG = 4;
20
30
  // Between the halves side by side, in the pictures' own pixels.
21
31
  const GAP = 16;
32
+ // How long blink shows each half, when it blinks by itself, in milliseconds.
33
+ const BLINK = 700;
34
+ // The largest the corner's map is drawn, in the stage's pixels.
35
+ const MAP = { width: 180, height: 120 };
36
+ // The least a region is drawn on the map, in the stage's pixels, so that a
37
+ // speck of change still shows.
38
+ const SPECK = 2;
39
+ // How much of the picture around a region its crops in the panel show, in
40
+ // the picture's pixels, and the most room they take there.
41
+ const AROUND = 16;
42
+ const CROP = { width: 200, height: 150 };
43
+ // Beyond so many regions the panel numbers no more of them: n and p reach the rest.
44
+ const NUMBERED = 60;
45
+ // How much stage is left around a region it is brought near to, in the
46
+ // stage's pixels.
47
+ const MARGIN = 24;
22
48
 
23
- const VIEWS = {
24
- s: ["side", "side by side"],
25
- w: ["swipe", "swipe"],
26
- b: ["blink", "blink"],
27
- o: ["onion", "onion skin"],
28
- d: ["difference", "difference"],
29
- };
49
+ // The ways a pair is looked at: the key that asks for each, the mode it sets,
50
+ // its name in the panel, and the line under that name while it is the one
51
+ // looked at. Blink has no line here: what it shows changes as it blinks.
52
+ const VIEWS = [
53
+ { key: "s", mode: "side", name: "Side by side", shows: "before, then after" },
54
+ { key: "w", mode: "swipe", name: "Swipe", shows: "drag the line across" },
55
+ { key: "b", mode: "blink", name: "Blink" },
56
+ {
57
+ key: "o",
58
+ mode: "onion",
59
+ name: "Onion skin",
60
+ shows: "the after, seen through",
61
+ },
62
+ {
63
+ key: "d",
64
+ mode: "difference",
65
+ name: "Difference",
66
+ shows: "only what changed is lit",
67
+ },
68
+ ];
30
69
 
31
70
  let dialog;
32
71
  let stage;
@@ -40,6 +79,8 @@ let caption;
40
79
  let keyRow;
41
80
  let closer;
42
81
  let marking;
82
+ let panel;
83
+ let map;
43
84
  // The notes drawn on the picture, and beside the one being written, its form.
44
85
  let notes;
45
86
  let beside;
@@ -57,20 +98,28 @@ let withdrawn = new Set();
57
98
  // The picture's own size, and where it is drawn: its scale, and its corner on the stage.
58
99
  let view = { scale: 1, x: 0, y: 0 };
59
100
  let fitted = 1;
60
- // How a pair is looked at, which half blink shows, where the swipe divides
61
- // it, and the regions where its halves differ, with the one gone to last.
101
+ // Whether the owner is pressing on the stage, which holds blink still.
102
+ let pressing = false;
103
+ // How a pair is looked at, which half blink shows and what turns it when it
104
+ // blinks by itself, where the swipe divides it, and the regions where its
105
+ // halves differ, with the one focused on.
62
106
  let mode = "side";
63
107
  let showing = "after";
108
+ let blinker;
64
109
  let divide = 0.5;
65
110
  let changes;
66
111
  let change = -1;
67
112
  const compared = new Map();
68
113
 
69
- const made = (tag, className, text) =>
70
- Object.assign(document.createElement(tag), {
71
- className,
72
- ...(text === undefined ? {} : { textContent: text }),
73
- });
114
+ // Puts content in an element: text, elements, and lists of them. What is
115
+ // false, empty or missing is left out, so that a part can say `when && it`.
116
+ const fill = (element, ...content) => {
117
+ element.replaceChildren(...content.flat().filter(Boolean));
118
+ return element;
119
+ };
120
+
121
+ const made = (tag, className, ...content) =>
122
+ fill(Object.assign(document.createElement(tag), { className }), ...content);
74
123
 
75
124
  const build = () => {
76
125
  dialog = made("dialog", "viewer");
@@ -90,7 +139,11 @@ const build = () => {
90
139
  marking.hidden = true;
91
140
  beside = made("div", "beside-note");
92
141
  beside.hidden = true;
93
- stage.append(canvas, marking, beside);
142
+ map = buildMap();
143
+ stage.append(canvas, marking, map, beside);
144
+ panel = made("aside", "compare");
145
+ panel.setAttribute("aria-label", "Comparing the pair");
146
+ panel.hidden = true;
94
147
  slider = Object.assign(made("input", "swipe"), {
95
148
  type: "range",
96
149
  min: 0,
@@ -107,13 +160,19 @@ const build = () => {
107
160
  closer.title = "Close (Esc)";
108
161
  closer.setAttribute("aria-label", "Close the picture");
109
162
  closer.addEventListener("click", () => dialog.close());
110
- dialog.append(stage, slider, caption, keyRow, closer);
163
+ dialog.append(stage, panel, slider, caption, keyRow, closer);
111
164
  document.body.append(dialog);
112
165
  listen();
113
166
  };
114
167
 
115
168
  const pair = () => !!pictures[at]?.pair;
116
169
  const halvesOf = (picture) => picture?.pair ?? (picture ? [picture] : []);
170
+ // Whether one of the regions where a pair's halves differ is focused on.
171
+ const focused = () => change >= 0;
172
+
173
+ // Only side by side lays the halves apart: every other view lays one over
174
+ // the other.
175
+ const sideBySide = (way) => way === "side";
117
176
 
118
177
  // The pictures as laid on the canvas, in their own pixels: one alone, or a
119
178
  // pair's halves side by side or one over the other, from the top left.
@@ -126,7 +185,7 @@ const layout = () => {
126
185
  if (!pair()) return { ...one, halves: [{ image: first, x: 0 }] };
127
186
  const other = size(second);
128
187
  const height = Math.max(one.height, other.height);
129
- if (mode === "side")
188
+ if (sideBySide(mode))
130
189
  return {
131
190
  width: one.width + GAP + other.width,
132
191
  height,
@@ -169,18 +228,105 @@ const draw = () => {
169
228
  dialog.focus({ preventScroll: true });
170
229
  outline(laid);
171
230
  drawNotes(laid);
172
- stage.dataset.zoomed = String(view.scale > fitted * 1.01);
231
+ drawMap(laid);
232
+ stage.dataset.zoomed = String(zoomedIn());
173
233
  };
174
234
 
175
- // A box on a half, in fractions of it, laid in the canvas's own pixels.
176
- const laidOn = (element, { image, x }, [left, top, width, height]) =>
235
+ // Whether the owner has come nearer than the size that shows it all.
236
+ const zoomedIn = () => view.scale > fitted * 1.01;
237
+
238
+ // Two kinds of box. One is in pixels, the pictures' or the stage's, from the
239
+ // top left: { left, top, width, height }, as a changed region is. A note's is
240
+ // four fractions of its half, [left, top, width, height], as the page draws
241
+ // it in percent.
242
+
243
+ // An element laid over a box of pixels: the box moved by (x, y), then drawn
244
+ // `scale` times as large, no side less than `least`.
245
+ const cover = (
246
+ element,
247
+ { left, top, width, height },
248
+ { scale = 1, x = 0, y = 0, least = 0 } = {},
249
+ ) =>
177
250
  Object.assign(element.style, {
178
- left: `${x + left * image.naturalWidth}px`,
179
- top: `${top * image.naturalHeight}px`,
180
- width: `${width * image.naturalWidth}px`,
181
- height: `${height * image.naturalHeight}px`,
251
+ left: `${(left + x) * scale}px`,
252
+ top: `${(top + y) * scale}px`,
253
+ width: `${Math.max(width * scale, least)}px`,
254
+ height: `${Math.max(height * scale, least)}px`,
182
255
  });
183
256
 
257
+ // A picture as a box of its own pixels.
258
+ const bounds = ({ naturalWidth: width, naturalHeight: height }) => ({
259
+ left: 0,
260
+ top: 0,
261
+ width,
262
+ height,
263
+ });
264
+
265
+ // A box with `by` more of the picture all around it.
266
+ const grown = ({ left, top, width, height }, by) => ({
267
+ left: left - by,
268
+ top: top - by,
269
+ width: width + by * 2,
270
+ height: height + by * 2,
271
+ });
272
+
273
+ // The part of a box that lies within a frame, which begins at the top left.
274
+ const clipped = (box, frame) => {
275
+ const left = Math.max(box.left, 0);
276
+ const top = Math.max(box.top, 0);
277
+ return {
278
+ left,
279
+ top,
280
+ width: Math.max(Math.min(box.left + box.width, frame.width) - left, 0),
281
+ height: Math.max(Math.min(box.top + box.height, frame.height) - top, 0),
282
+ };
283
+ };
284
+
285
+ // A box of the pictures' pixels as it lies on one half: held within that
286
+ // half's picture, or nothing when the half is smaller and does not reach it.
287
+ const lyingOn = (box, image) =>
288
+ box.left < image.naturalWidth && box.top < image.naturalHeight
289
+ ? clipped(box, bounds(image))
290
+ : undefined;
291
+
292
+ // The box two points are opposite corners of.
293
+ const boxBetween = (one, other) => ({
294
+ left: Math.min(one.x, other.x),
295
+ top: Math.min(one.y, other.y),
296
+ width: Math.abs(other.x - one.x),
297
+ height: Math.abs(other.y - one.y),
298
+ });
299
+
300
+ // A note's box, which is in fractions of a half, as the pixels of its picture,
301
+ // and back.
302
+ const pixelsOf = (
303
+ [left, top, width, height],
304
+ { naturalWidth, naturalHeight },
305
+ ) => ({
306
+ left: left * naturalWidth,
307
+ top: top * naturalHeight,
308
+ width: width * naturalWidth,
309
+ height: height * naturalHeight,
310
+ });
311
+
312
+ const fractionsOf = (
313
+ { left, top, width, height },
314
+ { naturalWidth, naturalHeight },
315
+ ) => [
316
+ left / naturalWidth,
317
+ top / naturalHeight,
318
+ width / naturalWidth,
319
+ height / naturalHeight,
320
+ ];
321
+
322
+ // A note's box on a half, laid in the canvas's own pixels.
323
+ const laidOn = (element, { image, x }, box) =>
324
+ cover(element, pixelsOf(box, image), { x });
325
+
326
+ // Where the pictures begin on the canvas: at two places side by side, at one
327
+ // when they lie over each other. A changed region lies at each.
328
+ const offsetsOf = (laid) => [...new Set(laid.halves.map(({ x }) => x))];
329
+
184
330
  // A note's box as the page draws it on a picture, in percent of it.
185
331
  const boxOf = (region) =>
186
332
  ["left", "top", "width", "height"].map(
@@ -217,7 +363,9 @@ const noteBox = (half, box, { id, index, drafted = false }) => {
217
363
  const pin = !box[2] && !box[3];
218
364
  const element = made(
219
365
  "span",
220
- ["note", ...(pin ? ["pin"] : []), ...(drafted ? ["drafted"] : [])].join(" "),
366
+ ["note", ...(pin ? ["pin"] : []), ...(drafted ? ["drafted"] : [])].join(
367
+ " ",
368
+ ),
221
369
  );
222
370
  laidOn(element, half, box);
223
371
  // A pin is as large on the screen at any zoom: its size is the stylesheet's.
@@ -260,37 +408,170 @@ const placeBeside = () => {
260
408
  Object.assign(beside.style, { left: `${x}px`, top: `${Math.max(y, 0)}px` });
261
409
  };
262
410
 
263
- // Each changed region outlined, on each half it shows on.
411
+ // What the region focused on is called where it is outlined: its number, and
412
+ // the half it is on, or both when they lie one over the other.
413
+ const tagOf = (place, halves) =>
414
+ `${change + 1} · ${
415
+ halves > 1
416
+ ? place
417
+ ? "after"
418
+ : "before"
419
+ : mode === "blink"
420
+ ? showing
421
+ : "before and after"
422
+ }`;
423
+
424
+ // Each changed region outlined, on each half it shows on; the one focused on
425
+ // says which it is.
264
426
  const outline = (laid) => {
265
427
  outlines.replaceChildren();
266
428
  if (!pair() || !changes?.boxes.length) return;
267
- const offsets = [...new Set(laid.halves.map(({ x }) => x))];
429
+ const offsets = offsetsOf(laid);
268
430
  changes.boxes.forEach((box, index) => {
269
- for (const x of offsets) {
431
+ offsets.forEach((x, place) => {
270
432
  const line = made("span", "change");
271
- if (index === change) line.dataset.current = "";
272
- Object.assign(line.style, {
273
- left: `${box.left + x}px`,
274
- top: `${box.top}px`,
275
- width: `${box.width}px`,
276
- height: `${box.height}px`,
277
- });
433
+ if (index === change) {
434
+ line.dataset.current = "";
435
+ line.append(made("span", "tag", tagOf(place, offsets.length)));
436
+ }
437
+ cover(line, box, { x });
278
438
  outlines.append(line);
279
- }
439
+ });
440
+ });
441
+ };
442
+
443
+ // The map in the stage's corner: what is looked at, small, with its regions
444
+ // and the part the stage shows. It shows only while the stage does not show
445
+ // all of it.
446
+ let mapCanvas;
447
+ let mapImages;
448
+ let mapRegions;
449
+ let mapShown;
450
+ const buildMap = () => {
451
+ const holder = made("div", "minimap");
452
+ holder.hidden = true;
453
+ holder.setAttribute("aria-hidden", "true");
454
+ mapCanvas = made("div", "map-canvas");
455
+ mapImages = [made("img", ""), made("img", "")];
456
+ for (const image of mapImages)
457
+ Object.assign(image, { alt: "", draggable: false });
458
+ mapRegions = made("div", "map-regions");
459
+ mapShown = made("span", "map-shown");
460
+ mapCanvas.append(...mapImages, mapRegions, mapShown);
461
+ holder.append(mapCanvas);
462
+ return holder;
463
+ };
464
+
465
+ const mapScale = (laid) =>
466
+ Math.min(MAP.width / laid.width, MAP.height / laid.height);
467
+
468
+ const drawMap = (laid) => {
469
+ const room = stage.getBoundingClientRect();
470
+ map.hidden =
471
+ !laid.width ||
472
+ onStageWhole({ left: 0, top: 0, width: laid.width, height: laid.height });
473
+ if (map.hidden) return;
474
+ const scale = mapScale(laid);
475
+ Object.assign(mapCanvas.style, {
476
+ width: `${laid.width * scale}px`,
477
+ height: `${laid.height * scale}px`,
280
478
  });
479
+ drawMapImages(laid, scale);
480
+ drawMapRegions(laid, scale);
481
+ drawMapShown(laid, room, scale);
482
+ };
483
+
484
+ // Each half, small, where the canvas has it; one the layout has no half for
485
+ // is not shown.
486
+ const drawMapImages = (laid, scale) => {
487
+ mapImages.forEach((mini, index) => {
488
+ const half = laid.halves[index];
489
+ mini.hidden = !half || half.image.hidden;
490
+ if (!half) return;
491
+ // Only when it changes: a wheel tick draws again.
492
+ if (mini.src !== half.image.src) mini.src = half.image.src;
493
+ cover(mini, bounds(half.image), { scale, x: half.x });
494
+ });
495
+ };
496
+
497
+ // Each region where the halves differ, at each place a half lies.
498
+ const drawMapRegions = (laid, scale) => {
499
+ const boxes = pair() ? (changes?.boxes ?? []) : [];
500
+ const offsets = offsetsOf(laid);
501
+ mapRegions.replaceChildren(
502
+ ...boxes.flatMap((box, index) =>
503
+ offsets.map((x) => {
504
+ const spot = made("span", "map-region");
505
+ if (index === change) spot.dataset.current = "";
506
+ cover(spot, box, { scale, x, least: SPECK });
507
+ return spot;
508
+ }),
509
+ ),
510
+ );
511
+ };
512
+
513
+ // The part of the pictures the stage shows, framed.
514
+ const drawMapShown = (laid, room, scale) => {
515
+ const visible = {
516
+ left: -view.x / view.scale,
517
+ top: -view.y / view.scale,
518
+ width: room.width / view.scale,
519
+ height: room.height / view.scale,
520
+ };
521
+ cover(mapShown, clipped(visible, laid), { scale });
522
+ };
523
+
524
+ // The stage centred on the place of the map pressed on, at the zoom there is.
525
+ const centreOnMapPoint = (event) => {
526
+ const scale = mapScale(layout());
527
+ const corner = mapCanvas.getBoundingClientRect();
528
+ const left = (event.clientX - corner.left) / scale;
529
+ const top = (event.clientY - corner.top) / scale;
530
+ view = centredOn({ left, top }, view.scale);
531
+ draw();
532
+ };
533
+
534
+ // The view that has the middle of a box of the canvas in the middle of the
535
+ // stage, at a scale. A box of no size is a point, as a pin is.
536
+ const centredOn = ({ left, top, width = 0, height = 0 }, scale) => {
537
+ const room = stage.getBoundingClientRect();
538
+ return {
539
+ scale,
540
+ x: room.width / 2 - (left + width / 2) * scale,
541
+ y: room.height / 2 - (top + height / 2) * scale,
542
+ };
543
+ };
544
+
545
+ // Whether a box of the canvas lies wholly on the stage as the view stands,
546
+ // give or take a pixel: the sums that fit a picture are never quite exact.
547
+ const onStageWhole = ({ left, top, width, height }) => {
548
+ const room = stage.getBoundingClientRect();
549
+ const x = view.x + left * view.scale;
550
+ const y = view.y + top * view.scale;
551
+ return (
552
+ x >= -1 &&
553
+ y >= -1 &&
554
+ x + width * view.scale <= room.width + 1 &&
555
+ y + height * view.scale <= room.height + 1
556
+ );
281
557
  };
282
558
 
283
- // At the size that shows all of it, never larger than it is, in the middle.
559
+ // The scale that shows all of it, never larger than it is.
560
+ const fittedScale = () => {
561
+ const room = stage.getBoundingClientRect();
562
+ const { width, height } = layout();
563
+ return width && room.width
564
+ ? Math.min(room.width / width, room.height / height, 1)
565
+ : fitted;
566
+ };
567
+
568
+ // At the size that shows all of it, in the middle.
284
569
  const fit = () => {
285
570
  const room = stage.getBoundingClientRect();
286
571
  const { width, height } = layout();
287
572
  if (!width || !room.width) return;
288
- fitted = Math.min(room.width / width, room.height / height, 1);
289
- view = {
290
- scale: fitted,
291
- x: (room.width - width * fitted) / 2,
292
- y: (room.height - height * fitted) / 2,
293
- };
573
+ fitted = fittedScale();
574
+ view = centredOn({ left: 0, top: 0, width, height }, fitted);
294
575
  draw();
295
576
  };
296
577
 
@@ -364,93 +645,255 @@ const onPicture = (point, half) => {
364
645
  };
365
646
  };
366
647
 
367
- const plural = (count, one) => `${count} ${one}${count === 1 ? "" : "s"}`;
368
-
369
- // What the caption says of a pair: how it is looked at, and what the
370
- // comparison of its halves found, once it has.
371
- const comparing = () => {
372
- const name = Object.values(VIEWS).find(([key]) => key === mode)[1];
373
- const looked =
374
- mode === "blink"
375
- ? `${name}: ${showing}`
376
- : name.replace(/^./, (letter) => letter.toUpperCase());
377
- if (!changes) return [looked, "Comparing…"];
378
- if (!changes.compared) return [looked, "Regions need ImageMagick"];
379
- const sized = new Set(changes.sizes.map((size) => size.join("x"))).size > 1;
380
- const found = changes.boxes.length
381
- ? change >= 0
382
- ? `region ${change + 1} of ${changes.boxes.length}`
383
- : plural(changes.boxes.length, "changed region")
384
- : "Identical";
385
- return [
386
- looked,
387
- sized ? "Different sizes: compared from the top left" : "",
388
- found,
389
- ];
390
- };
391
-
392
- const say = () => {
393
- const picture = pictures[at];
648
+ // The caption names the picture, and its place among those opened; how a
649
+ // pair is looked at, and what its comparison found, are the panel's to say.
650
+ const sayCaption = () => {
394
651
  caption.textContent = [
395
652
  pictures.length > 1 ? `${at + 1} of ${pictures.length}` : "",
396
- picture.name ?? "",
397
- ...(pair() ? comparing() : []),
653
+ pictures[at].name ?? "",
398
654
  ]
399
655
  .filter(Boolean)
400
656
  .join(" · ");
657
+ };
658
+
659
+ const say = () => {
660
+ sayCaption();
401
661
  keys();
662
+ describe();
402
663
  };
403
664
 
404
- const keyHint = (pressed, does, current = false) => {
405
- const hint = made("span", current ? "current" : "");
406
- hint.append(
407
- ...pressed.split(" ").map((key) => made("kbd", "", key)),
665
+ const keyHint = (keycaps, does) =>
666
+ made(
667
+ "span",
668
+ "",
669
+ keycaps.split(" ").map((key) => made("kbd", "", key)),
408
670
  ` ${does}`,
409
671
  );
410
- return hint;
411
- };
412
-
413
- // The keys of what is open: a pair's views and regions, and where a mark
414
- // can go in the view it is looked at in.
415
- const keys = () => {
416
- if (draft)
417
- return keyRow.replaceChildren(
418
- keyHint("⌘ Enter", "save the note"),
419
- keyHint("Esc", "put it away"),
420
- keyHint("scroll", "zoom"),
421
- keyHint("drag", "move"),
422
- ...(draft.note
423
- ? openable()
424
- ? [keyHint("click", "another note: open it")]
425
- : []
426
- : [keyHint("Shift drag", "mark again")]),
672
+
673
+ // The keys at the foot: while a note is written, those that end it; otherwise
674
+ // those of the picture itself, and where a mark can go in the view it is
675
+ // looked at in. A pair's views and regions are the panel's; + - and the arrows
676
+ // go on working unsaid, as they do everywhere.
677
+ const keys = () => fill(keyRow, draft ? writingKeys() : lookingKeys());
678
+
679
+ const writingKeys = () => [
680
+ keyHint("⌘ Enter", "save the note"),
681
+ keyHint("Esc", "put it away"),
682
+ keyHint("scroll", "zoom"),
683
+ dragHint(),
684
+ // A new note's place can be marked again; a note written before can give
685
+ // way to another.
686
+ draft.note
687
+ ? openable() && keyHint("click", "another note: open it")
688
+ : keyHint("⇧drag", "mark again"),
689
+ ];
690
+
691
+ const lookingKeys = () => [
692
+ keyHint("scroll", "zoom"),
693
+ dragHint(),
694
+ keyHint("0", "fit"),
695
+ keyHint("1", "full size"),
696
+ markable()
697
+ ? keyHint("click ⇧drag", "note")
698
+ : annotate && made("span", "off", "no marking in this view"),
699
+ pictures.length > 1 && keyHint("[ ]", "other pictures"),
700
+ keyHint("Esc", "close"),
701
+ ];
702
+
703
+ // In swipe a drag moves the divider, a note being written or not.
704
+ const dragHint = () =>
705
+ keyHint("drag", pair() && mode === "swipe" ? "the divider" : "move");
706
+
707
+ // A button of the panel. `pressed` says whether what it turns on is on,
708
+ // `current` marks the one the panel is at, and `label` is what is read out for
709
+ // a face that does not say it, a number.
710
+ const control = (
711
+ className,
712
+ content,
713
+ action,
714
+ { pressed, current, label } = {},
715
+ ) => {
716
+ const button = made("button", `quiet ${className}`, content);
717
+ button.type = "button";
718
+ if (pressed !== undefined)
719
+ button.setAttribute("aria-pressed", String(pressed));
720
+ if (label) button.setAttribute("aria-label", label);
721
+ if (current) button.setAttribute("aria-current", "true");
722
+ button.addEventListener("click", action);
723
+ return button;
724
+ };
725
+
726
+ // The panel beside a pair: the ways to look at it, then where its halves differ.
727
+ const describe = () => {
728
+ panel.hidden = !pair();
729
+ fill(panel, pair() && [viewsPart(), regionsPart()]);
730
+ scrollToHeld();
731
+ };
732
+
733
+ // Only the panel scrolls: far enough down to show all of the region focused
734
+ // on, or, when it is taller than the panel, to its top.
735
+ const scrollToHeld = () => {
736
+ const held = panel.querySelector(".held");
737
+ if (!held) return;
738
+ const room = panel.getBoundingClientRect();
739
+ const box = held.getBoundingClientRect();
740
+ const below = box.bottom - room.bottom;
741
+ if (below > 0) panel.scrollTop += Math.min(below, box.top - room.top);
742
+ };
743
+
744
+ // Each way of looking by its key and name; the one it is looked at in says
745
+ // what it shows.
746
+ const viewsPart = () =>
747
+ made("section", "ways", made("h2", "", "Compare"), VIEWS.map(wayButton));
748
+
749
+ const wayButton = (way) => {
750
+ const looking = mode === way.mode;
751
+ return control(
752
+ "way",
753
+ [
754
+ made("kbd", "", way.key),
755
+ made("span", "name", way.name),
756
+ looking && whatItShows(way),
757
+ ],
758
+ () => look(way.mode),
759
+ { pressed: looking },
760
+ );
761
+ };
762
+
763
+ const whatItShows = (way) =>
764
+ made("span", "shows", way.mode === "blink" ? blinkLine() : way.shows);
765
+
766
+ // Blink shows one half at a time: which, and the keys that go on from there.
767
+ const blinkLine = () => [
768
+ made("span", "showing", showing),
769
+ keyHint("b", "switch"),
770
+ keyHint("⇧B", blinker ? "stop" : "blink"),
771
+ ];
772
+
773
+ // Why the panel lists no regions, when it lists none: the comparison is not
774
+ // in, could not be made, or found the pictures alike.
775
+ const whyNoRegions = () => {
776
+ if (!changes) return "Comparing…";
777
+ if (!changes.compared) return "Finding them needs ImageMagick.";
778
+ if (!changes.boxes.length) return "Nothing: the same pixels.";
779
+ };
780
+
781
+ const sizesDiffer = () =>
782
+ new Set(changes.sizes.map((size) => size.join("x"))).size > 1;
783
+
784
+ // Where the halves differ: how many, each by its number, the keys that go
785
+ // through them, and the one focused on as each half has it.
786
+ const regionsPart = () => {
787
+ const why = whyNoRegions();
788
+ if (why)
789
+ return made(
790
+ "section",
791
+ "differences",
792
+ made("h2", "", "Changed"),
793
+ made("p", "", why),
427
794
  );
428
- keyRow.replaceChildren(
429
- keyHint("scroll + -", "zoom"),
430
- keyHint(mode === "swipe" && pair() ? "← ↑ ↓ →" : "drag ← ↑ ↓ →", "move"),
431
- keyHint("0", "fit"),
432
- keyHint("1", "full size"),
433
- ...(markable()
434
- ? [
435
- keyHint("click", "mark a point"),
436
- keyHint("Shift drag", "mark a region"),
437
- ...(openable() ? [keyHint("click", "a note: open it")] : []),
438
- ]
439
- : annotate
440
- ? [made("span", "off", "marking is off in this view")]
441
- : []),
442
- ...(pair()
443
- ? [
444
- ...Object.entries(VIEWS).map(([key, [name, said]]) =>
445
- keyHint(key, said, mode === name),
446
- ),
447
- ...(mode === "swipe" ? [keyHint("drag", "the divider")] : []),
448
- keyHint("n", "next region"),
449
- ]
450
- : []),
451
- keyHint("[ ]", "other pictures"),
452
- keyHint("Esc", "close"),
795
+ const count = changes.boxes.length;
796
+ return made(
797
+ "section",
798
+ "differences",
799
+ made("h2", "", "Changed ", made("span", "count", String(count))),
800
+ sizesDiffer() && made("p", "", "Sizes differ: laid from the top left."),
801
+ places(),
802
+ going(),
803
+ focused() && heldRegion(),
804
+ );
805
+ };
806
+
807
+ // The regions by number, as many as the panel numbers.
808
+ const places = () =>
809
+ made(
810
+ "ol",
811
+ "places",
812
+ changes.boxes.slice(0, NUMBERED).map((_, index) => placeItem(index)),
813
+ );
814
+
815
+ const placeItem = (index) =>
816
+ made(
817
+ "li",
818
+ "",
819
+ control("place", String(index + 1), () => focusChange(index), {
820
+ label: `Region ${index + 1}`,
821
+ current: index === change,
822
+ }),
823
+ );
824
+
825
+ // The keys that go through the regions, and, once one is focused on, those
826
+ // that act on it.
827
+ const going = () =>
828
+ made(
829
+ "p",
830
+ "going",
831
+ keyHint("n p", "next, previous"),
832
+ focused() && keyHint("z", "zoom in"),
833
+ focused() && annotate && keyHint("Enter", "note"),
834
+ );
835
+
836
+ // The region focused on, as the before has it and as the after does.
837
+ const heldRegion = () => {
838
+ const box = changes.boxes[change];
839
+ return made("div", "held", crop(0, box), crop(1, box));
840
+ };
841
+
842
+ // A little of a half's picture around a region, as large as the panel has
843
+ // room for, with the region outlined in it.
844
+ const framed = (image, region) => {
845
+ const around = clipped(grown(region, AROUND), bounds(image));
846
+ const scale = Math.min(
847
+ CROP.width / around.width,
848
+ CROP.height / around.height,
849
+ 4,
453
850
  );
851
+ // All in the frame is laid from the corner of what it shows.
852
+ const inFrame = { scale, x: -around.left, y: -around.top };
853
+ const frame = made("span", "frame");
854
+ cover(frame, around, inFrame);
855
+ const shown = Object.assign(made("img", ""), {
856
+ src: image.src,
857
+ alt: "",
858
+ draggable: false,
859
+ });
860
+ cover(shown, bounds(image), inFrame);
861
+ const line = made("span", "change");
862
+ cover(line, region, inFrame);
863
+ frame.append(shown, line);
864
+ return frame;
865
+ };
866
+
867
+ // One half's crop of a region: a button that writes a note on it there, when
868
+ // the picture takes notes, or what the half lacks, when it is smaller.
869
+ const crop = (half, box) => {
870
+ const image = [first, second][half];
871
+ const name = half ? "after" : "before";
872
+ const holder = annotate
873
+ ? control("crop", [], () => noteOnChange(half))
874
+ : made("div", "crop");
875
+ const reached = lyingOn(box, image);
876
+ if (!reached) {
877
+ holder.append(made("span", "label", `${name}: smaller, not here`));
878
+ holder.disabled = true;
879
+ return holder;
880
+ }
881
+ holder.append(framed(image, reached), made("span", "label", name));
882
+ if (annotate)
883
+ holder.setAttribute(
884
+ "aria-label",
885
+ `Note on region ${change + 1} in the ${name}`,
886
+ );
887
+ return holder;
888
+ };
889
+
890
+ // A note on the region focused on, in the half given: its box, in fractions
891
+ // of that half, as far as the half reaches.
892
+ const noteOnChange = (half = 1) => {
893
+ if (!annotate || !pair() || !focused()) return;
894
+ const image = [first, second][half];
895
+ const reached = lyingOn(changes.boxes[change], image);
896
+ if (reached) mark(half, fractionsOf(reached, image));
454
897
  };
455
898
 
456
899
  const compare = async (picture) => {
@@ -490,6 +933,7 @@ const show = (index) => {
490
933
  second.alt = halves[1].alt ?? "";
491
934
  compare(picture);
492
935
  } else second.removeAttribute("src");
936
+ stopBlinking();
493
937
  say();
494
938
  // Fitted once, when it is known how large it is: the late load of a
495
939
  // picture already at hand must not undo a zoom made since.
@@ -501,42 +945,99 @@ const show = (index) => {
501
945
  ).then(() => pictures[at] === shown && dialog.open && fit());
502
946
  };
503
947
 
504
- // Looks at a pair another way, holding the zoom when the layout stays.
505
- const look = (name) => {
948
+ // Blink shows the other half at the owner's word, or, set going, by itself.
949
+ // The panel is not drawn again for it, only its saying which half is shown:
950
+ // drawn every turn, its buttons would be replaced under the pointer, and a
951
+ // click that began on one would be lost.
952
+ const switchHalf = () => {
953
+ showing = showing === "after" ? "before" : "after";
954
+ draw();
955
+ panel.querySelector(".showing")?.replaceChildren(showing);
956
+ };
957
+
958
+ // A turn is skipped while the owner presses on the stage or writes a note: a
959
+ // mark goes on the half showing, which holds still until it is done.
960
+ const startBlinking = () => {
961
+ if (mode !== "blink") look("blink");
962
+ blinker = setInterval(() => {
963
+ if (!pressing && !draft) switchHalf();
964
+ }, BLINK);
965
+ };
966
+
967
+ const stopBlinking = () => {
968
+ clearInterval(blinker);
969
+ blinker = undefined;
970
+ };
971
+
972
+ const blinkByItself = () => {
973
+ if (!pair()) return;
974
+ if (blinker) stopBlinking();
975
+ else startBlinking();
976
+ describe();
977
+ };
978
+
979
+ // The layout has changed, so what shows all of it has too. A region focused
980
+ // on is kept in view at the owner's zoom, judged against the new fit and not
981
+ // the old; with none, all of it is fitted again.
982
+ const reframe = () => {
983
+ if (!focused()) return fit();
984
+ fitted = fittedScale();
985
+ focusChange(change);
986
+ };
987
+
988
+ // Looks at a pair another way, holding the zoom when the layout stays, and
989
+ // the region focused on when it does not. The way it is looked at already,
990
+ // asked for again, goes back to side by side; blink, asked again, shows the
991
+ // other half.
992
+ const look = (asked) => {
506
993
  if (!pair()) return;
994
+ const again = asked === mode;
995
+ if (again && mode === "blink") return switchHalf();
507
996
  const before = mode;
508
- showing =
509
- name === "blink" && mode === "blink" && showing === "after"
510
- ? "before"
511
- : "after";
512
- mode = name;
513
- if ((before === "side") !== (name === "side")) fit();
514
- else draw();
997
+ mode = again ? "side" : asked;
998
+ showing = "after";
999
+ stopBlinking();
1000
+ if (sideBySide(before) === sideBySide(mode)) draw();
1001
+ else reframe();
515
1002
  say();
516
1003
  };
517
1004
 
518
- // To the next region where the halves differ: near enough to see it whole.
519
- const nextChange = () => {
520
- if (!pair() || !changes?.boxes.length) return;
521
- change = (change + 1) % changes.boxes.length;
522
- const box = changes.boxes[change];
523
- const laid = layout();
524
- const x = laid.halves.at(-1).x + box.left;
1005
+ // A region as it lies on the canvas, on the half a focus goes to: the last,
1006
+ // the after.
1007
+ const onCanvas = (box) => ({
1008
+ ...box,
1009
+ left: box.left + layout().halves.at(-1).x,
1010
+ });
1011
+
1012
+ // The scale at which the stage shows a box whole with MARGIN around it, no
1013
+ // farther than all of the picture is shown and no nearer than the viewer goes.
1014
+ const scaleNear = ({ width, height }) => {
525
1015
  const room = stage.getBoundingClientRect();
526
- const margin = 24;
527
- const scale = Math.min(
528
- room.width / (box.width + margin * 2),
529
- room.height / (box.height + margin * 2),
530
- Math.max(NEAREST, fitted),
1016
+ const toSeeWhole = Math.min(
1017
+ room.width / (width + MARGIN * 2),
1018
+ room.height / (height + MARGIN * 2),
531
1019
  );
532
- view = {
533
- scale: Math.max(scale, fitted),
534
- x: room.width / 2 - (x + box.width / 2) * Math.max(scale, fitted),
535
- y: room.height / 2 - (box.top + box.height / 2) * Math.max(scale, fitted),
536
- };
1020
+ const nearest = Math.max(NEAREST, fitted);
1021
+ return Math.max(Math.min(toSeeWhole, nearest), fitted);
1022
+ };
1023
+
1024
+ // Focuses on a region where the halves differ, by its place among them, at
1025
+ // the zoom the owner chose: seen whole, it is outlined where it stands, and
1026
+ // brought into view only if it is out of it; zoomed in, or asked to come
1027
+ // `near`, the stage goes near enough to see it whole.
1028
+ const focusChange = (index, { near = zoomedIn() } = {}) => {
1029
+ if (!pair() || !changes?.boxes.length) return;
1030
+ const count = changes.boxes.length;
1031
+ change = (index + count) % count;
1032
+ const region = onCanvas(changes.boxes[change]);
1033
+ if (near) view = centredOn(region, scaleNear(region));
1034
+ else if (!onStageWhole(region)) view = centredOn(region, view.scale);
537
1035
  draw();
538
1036
  say();
539
1037
  };
1038
+ const nextChange = () => focusChange(change + 1);
1039
+ const previousChange = () => focusChange(change < 0 ? -1 : change - 1);
1040
+ const nearChange = () => focusChange(Math.max(change, 0), { near: true });
540
1041
 
541
1042
  // Whether the form open holds words not saved: a note written before is
542
1043
  // measured against what it said.
@@ -575,6 +1076,8 @@ const write = (half, box, note) => {
575
1076
  return;
576
1077
  }
577
1078
  draft = { half, box, figure: marked.figure, note };
1079
+ // Blink holds on the half the note is written on.
1080
+ if (pair() && mode === "blink") showing = half ? "after" : "before";
578
1081
  draw();
579
1082
  say();
580
1083
  };
@@ -594,7 +1097,8 @@ const putAway = () => {
594
1097
  const kept = ({ detail: { id, withdrawn: gone } }) => {
595
1098
  const { figure, box, note } = draft;
596
1099
  if (gone) withdrawn.add(id);
597
- else if (!note) written.set(figure, [...(written.get(figure) ?? []), { id, box }]);
1100
+ else if (!note)
1101
+ written.set(figure, [...(written.get(figure) ?? []), { id, box }]);
598
1102
  putAway();
599
1103
  };
600
1104
 
@@ -622,9 +1126,22 @@ const listen = () => {
622
1126
  { passive: false },
623
1127
  );
624
1128
 
1129
+ // The map goes where it is pressed, and follows a drag on it.
1130
+ map.addEventListener("pointerdown", (event) => {
1131
+ if (event.button !== 0) return;
1132
+ event.stopPropagation();
1133
+ map.setPointerCapture(event.pointerId);
1134
+ centreOnMapPoint(event);
1135
+ });
1136
+ map.addEventListener("pointermove", (event) => {
1137
+ if (map.hasPointerCapture(event.pointerId)) centreOnMapPoint(event);
1138
+ });
1139
+
625
1140
  stage.addEventListener("pointerdown", (event) => {
626
1141
  if (event.button !== 0 || beside.contains(event.target)) return;
1142
+ if (map.contains(event.target)) return;
627
1143
  stage.setPointerCapture(event.pointerId);
1144
+ pressing = true;
628
1145
  touching.set(event.pointerId, onStage(event));
629
1146
  const from = onStage(event);
630
1147
  drag = {
@@ -669,14 +1186,10 @@ const listen = () => {
669
1186
  else if (drag.marking) {
670
1187
  if (!drag.moved) return;
671
1188
  // Drawn as it will be kept: within the half it began on.
672
- const one = heldIn(drag.from, drag.half);
673
- const other = heldIn(now, drag.half);
674
- Object.assign(marking.style, {
675
- left: `${Math.min(one.x, other.x)}px`,
676
- top: `${Math.min(one.y, other.y)}px`,
677
- width: `${Math.abs(other.x - one.x)}px`,
678
- height: `${Math.abs(other.y - one.y)}px`,
679
- });
1189
+ cover(
1190
+ marking,
1191
+ boxBetween(heldIn(drag.from, drag.half), heldIn(now, drag.half)),
1192
+ );
680
1193
  marking.hidden = false;
681
1194
  } else if (drag.moved) {
682
1195
  move(now.x - drag.last.x, now.y - drag.last.y);
@@ -688,6 +1201,7 @@ const listen = () => {
688
1201
  if (!touching.delete(event.pointerId) || !drag || touching.size) return;
689
1202
  const { from, moved, marking: drawn, swiping, half, note } = drag;
690
1203
  drag = undefined;
1204
+ pressing = false;
691
1205
  marking.hidden = true;
692
1206
  const here = onStage(event);
693
1207
  if (event.type === "pointercancel" || swiping || half === undefined) return;
@@ -697,14 +1211,11 @@ const listen = () => {
697
1211
  return mark(half, [point.x, point.y, 0, 0]);
698
1212
  }
699
1213
  if (!drawn) return;
700
- const one = onPicture(from, half);
701
- const other = onPicture(here, half);
702
- mark(half, [
703
- Math.min(one.x, other.x),
704
- Math.min(one.y, other.y),
705
- Math.abs(other.x - one.x),
706
- Math.abs(other.y - one.y),
707
- ]);
1214
+ const { left, top, width, height } = boxBetween(
1215
+ onPicture(from, half),
1216
+ onPicture(here, half),
1217
+ );
1218
+ mark(half, [left, top, width, height]);
708
1219
  };
709
1220
  stage.addEventListener("pointerup", release);
710
1221
  stage.addEventListener("pointercancel", release);
@@ -714,6 +1225,11 @@ const listen = () => {
714
1225
  draw();
715
1226
  });
716
1227
 
1228
+ // A press on the panel leaves the keys with the viewer.
1229
+ panel.addEventListener("mousedown", (event) => {
1230
+ if (event.target.closest("button")) event.preventDefault();
1231
+ });
1232
+
717
1233
  const STEP = 80;
718
1234
  const actions = {
719
1235
  "+": () => zoom(1.25),
@@ -730,9 +1246,11 @@ const listen = () => {
730
1246
  "]": () => show(at + 1),
731
1247
  " ": () => dialog.close(),
732
1248
  n: nextChange,
733
- ...Object.fromEntries(
734
- Object.entries(VIEWS).map(([key, [name]]) => [key, () => look(name)]),
735
- ),
1249
+ p: previousChange,
1250
+ z: nearChange,
1251
+ Enter: () => noteOnChange(1),
1252
+ B: blinkByItself,
1253
+ ...Object.fromEntries(VIEWS.map((way) => [way.key, () => look(way.mode)])),
736
1254
  };
737
1255
  dialog.addEventListener("keydown", (event) => {
738
1256
  if (event.metaKey || event.ctrlKey || event.altKey) return;
@@ -752,9 +1270,16 @@ const listen = () => {
752
1270
  if (beside.contains(event.target)) return;
753
1271
  // The divider, while it has the focus, moves on the arrows itself.
754
1272
  if (event.target === slider && event.key.startsWith("Arrow")) return;
755
- if (!Object.hasOwn(actions, event.key)) return;
1273
+ // A button of the panel reached by Tab is pressed as a button is.
1274
+ if (
1275
+ event.target.closest?.(".compare") &&
1276
+ ["Enter", " "].includes(event.key)
1277
+ )
1278
+ return;
1279
+ const key = keyOf(event);
1280
+ if (!Object.hasOwn(actions, key)) return;
756
1281
  event.preventDefault();
757
- actions[event.key]();
1282
+ actions[key]();
758
1283
  });
759
1284
 
760
1285
  beside.addEventListener("closed", putAway);
@@ -762,7 +1287,9 @@ const listen = () => {
762
1287
 
763
1288
  addEventListener("resize", () => dialog.open && fit());
764
1289
  dialog.addEventListener("close", () => {
1290
+ stopBlinking();
765
1291
  draft = undefined;
1292
+ pressing = false;
766
1293
  beside.replaceChildren();
767
1294
  beside.hidden = true;
768
1295
  // The browser gives the focus back as it closes; this event comes after,