proctolog 0.1.1 → 0.3.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +111 -3
- data/README.md +32 -18
- data/app/controllers/proctolog/application_controller.rb +2 -2
- data/app/controllers/proctolog/bursts_controller.rb +1 -1
- data/app/controllers/proctolog/choices_controller.rb +3 -1
- data/app/controllers/proctolog/decisions_controller.rb +1 -1
- data/app/controllers/proctolog/notes_controller.rb +2 -2
- data/app/controllers/proctolog/prompts_controller.rb +1 -1
- data/app/controllers/proctolog/pull_requests_controller.rb +2 -4
- data/app/controllers/proctolog/review_data_controller.rb +3 -1
- data/app/controllers/proctolog/shares_controller.rb +27 -0
- data/app/helpers/proctolog/decisions_helper.rb +6 -2
- data/app/helpers/proctolog/desk_helper.rb +48 -8
- data/app/helpers/proctolog/rounds_helper.rb +4 -2
- data/app/helpers/proctolog/shares_helper.rb +11 -0
- data/app/helpers/proctolog/standings_helper.rb +5 -4
- data/app/models/proctolog/burst.rb +17 -8
- data/app/models/proctolog/choices.rb +8 -0
- data/app/models/proctolog/doctor.rb +23 -12
- data/app/models/proctolog/github.rb +1 -1
- data/app/models/proctolog/localhost.rb +17 -0
- data/app/models/proctolog/marks.rb +1 -1
- data/app/models/proctolog/note.rb +4 -0
- data/app/models/proctolog/owner.rb +3 -0
- data/app/models/proctolog/pick.rb +12 -0
- data/app/models/proctolog/rounds.rb +37 -0
- data/app/models/proctolog/server.rb +1 -7
- data/app/models/proctolog/setup.rb +190 -0
- data/app/models/proctolog/share/ngrok.rb +120 -0
- data/app/models/proctolog/share/settings.rb +23 -0
- data/app/models/proctolog/share/tailscale.rb +103 -0
- data/app/models/proctolog/share.rb +101 -0
- data/app/models/proctolog/standing.rb +24 -7
- data/app/views/proctolog/bursts/_line.html.erb +2 -2
- data/app/views/proctolog/bursts/_side.html.erb +6 -5
- data/app/views/proctolog/bursts/_side_stop.html.erb +4 -4
- data/app/views/proctolog/notes/_card.html.erb +7 -3
- data/app/views/proctolog/open_pull_requests/_side.html.erb +4 -3
- data/app/views/proctolog/open_pull_requests/_start.html.erb +8 -0
- data/app/views/proctolog/open_pull_requests/index.html.erb +4 -5
- data/app/views/proctolog/open_pull_requests/start/_first_time.html.erb +3 -0
- data/app/views/proctolog/open_pull_requests/start/_nothing_open.html.erb +3 -0
- data/app/views/proctolog/open_pull_requests/start/_nothing_written.html.erb +3 -0
- data/app/views/proctolog/shares/_line.html.erb +5 -0
- data/app/views/proctolog/shares/show.html.erb +48 -0
- data/app/views/proctolog/shares/ways/_ngrok.html.erb +6 -0
- data/app/views/proctolog/shares/ways/_tailscale.html.erb +2 -0
- data/bin/proctolog +154 -27
- data/completions/_proctolog +31 -6
- data/config/application.rb +23 -4
- data/config/initializers/grouse.rb +1 -1
- data/config/routes.rb +3 -0
- data/docs/manual/bursts.md +7 -2
- data/docs/manual/deciding.md +4 -1
- data/docs/manual/handover.md +6 -0
- data/docs/manual/pr-format.md +1 -1
- data/docs/manual/reading.md +54 -27
- data/docs/manual/setup.md +59 -0
- data/docs/manual/sharing.md +77 -0
- data/docs/manual/updates.md +11 -4
- data/lib/proctolog/changelog/news.rb +67 -0
- data/lib/proctolog/changelog.rb +75 -0
- data/lib/proctolog/installation.rb +19 -0
- data/lib/proctolog/version.rb +1 -1
- data/public/asking.js +31 -4
- data/public/decisions.js +4 -4
- data/public/desk.js +10 -6
- data/public/keys.js +6 -0
- data/public/notes.js +164 -58
- data/public/pick.css +21 -0
- data/public/respond.css +6 -0
- data/public/respond.js +51 -7
- data/public/review-drafts.js +47 -9
- data/public/review-rounds.js +27 -3
- data/public/share.css +58 -0
- data/public/side.js +11 -20
- data/public/terminal.css +295 -34
- data/public/viewer.js +700 -173
- metadata +22 -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,
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
//
|
|
61
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
231
|
+
drawMap(laid);
|
|
232
|
+
stage.dataset.zoomed = String(zoomedIn());
|
|
173
233
|
};
|
|
174
234
|
|
|
175
|
-
//
|
|
176
|
-
const
|
|
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: `${
|
|
179
|
-
top: `${top *
|
|
180
|
-
width: `${width *
|
|
181
|
-
height: `${height *
|
|
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
|
-
//
|
|
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 =
|
|
429
|
+
const offsets = offsetsOf(laid);
|
|
268
430
|
changes.boxes.forEach((box, index) => {
|
|
269
|
-
|
|
431
|
+
offsets.forEach((x, place) => {
|
|
270
432
|
const line = made("span", "change");
|
|
271
|
-
if (index === change)
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
//
|
|
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 =
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
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 = (
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
665
|
+
const keyHint = (keycaps, does) =>
|
|
666
|
+
made(
|
|
667
|
+
"span",
|
|
668
|
+
"",
|
|
669
|
+
keycaps.split(" ").map((key) => made("kbd", "", key)),
|
|
408
670
|
` ${does}`,
|
|
409
671
|
);
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
const keys = () =>
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
//
|
|
505
|
-
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
//
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
|
527
|
-
|
|
528
|
-
room.
|
|
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
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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)
|
|
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
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
|
|
734
|
-
|
|
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
|
-
|
|
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[
|
|
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,
|