proctolog 0.1.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 +7 -0
- data/CHANGELOG.md +23 -0
- data/README.md +178 -0
- data/app/controllers/proctolog/acceptances_controller.rb +29 -0
- data/app/controllers/proctolog/application_controller.rb +32 -0
- data/app/controllers/proctolog/bursts_controller.rb +34 -0
- data/app/controllers/proctolog/choices_controller.rb +30 -0
- data/app/controllers/proctolog/code_reviews_controller.rb +26 -0
- data/app/controllers/proctolog/decisions_controller.rb +33 -0
- data/app/controllers/proctolog/entries_controller.rb +14 -0
- data/app/controllers/proctolog/evidence_controller.rb +23 -0
- data/app/controllers/proctolog/freshness_controller.rb +27 -0
- data/app/controllers/proctolog/good_to_go_controller.rb +29 -0
- data/app/controllers/proctolog/memberships_controller.rb +20 -0
- data/app/controllers/proctolog/mentions_controller.rb +16 -0
- data/app/controllers/proctolog/moments_controller.rb +26 -0
- data/app/controllers/proctolog/note_params.rb +24 -0
- data/app/controllers/proctolog/notes_controller.rb +56 -0
- data/app/controllers/proctolog/open_pull_requests_controller.rb +59 -0
- data/app/controllers/proctolog/pictures_controller.rb +27 -0
- data/app/controllers/proctolog/prompts_controller.rb +28 -0
- data/app/controllers/proctolog/pull_request_scoped.rb +21 -0
- data/app/controllers/proctolog/pull_requests_controller.rb +34 -0
- data/app/controllers/proctolog/put_away_controller.rb +19 -0
- data/app/controllers/proctolog/recordings_controller.rb +28 -0
- data/app/controllers/proctolog/rendered_pieces_controller.rb +11 -0
- data/app/controllers/proctolog/review_data_controller.rb +12 -0
- data/app/controllers/proctolog/rules_controller.rb +20 -0
- data/app/controllers/proctolog/scenario_notes_controller.rb +26 -0
- data/app/controllers/proctolog/scenario_scoped.rb +21 -0
- data/app/controllers/proctolog/scenarios_controller.rb +67 -0
- data/app/controllers/proctolog/sitting_servers_controller.rb +30 -0
- data/app/controllers/proctolog/waiting_controller.rb +14 -0
- data/app/controllers/proctolog/with_decisions.rb +28 -0
- data/app/helpers/proctolog/bursts_helper.rb +52 -0
- data/app/helpers/proctolog/decisions_helper.rb +102 -0
- data/app/helpers/proctolog/desk_helper.rb +61 -0
- data/app/helpers/proctolog/mentions_helper.rb +27 -0
- data/app/helpers/proctolog/open_pull_requests_helper.rb +31 -0
- data/app/helpers/proctolog/prompts_helper.rb +21 -0
- data/app/helpers/proctolog/pull_requests_helper.rb +67 -0
- data/app/helpers/proctolog/recordings_helper.rb +30 -0
- data/app/helpers/proctolog/rounds_helper.rb +27 -0
- data/app/helpers/proctolog/scenarios_helper.rb +123 -0
- data/app/helpers/proctolog/standings_helper.rb +80 -0
- data/app/jobs/proctolog/application_job.rb +8 -0
- data/app/jobs/proctolog/ask_for_release_job.rb +9 -0
- data/app/jobs/proctolog/fetch_pictures_ahead_job.rb +36 -0
- data/app/jobs/proctolog/prune_fresh_profiles_job.rb +9 -0
- data/app/jobs/proctolog/read_mentions_ahead_job.rb +12 -0
- data/app/models/proctolog/acceptance.rb +47 -0
- data/app/models/proctolog/app.rb +92 -0
- data/app/models/proctolog/browser.rb +42 -0
- data/app/models/proctolog/burst.rb +268 -0
- data/app/models/proctolog/burst_notices.rb +143 -0
- data/app/models/proctolog/chapters.rb +42 -0
- data/app/models/proctolog/check.rb +122 -0
- data/app/models/proctolog/choices.rb +112 -0
- data/app/models/proctolog/code_review.rb +251 -0
- data/app/models/proctolog/decision_record.rb +12 -0
- data/app/models/proctolog/decisions.rb +199 -0
- data/app/models/proctolog/description.rb +165 -0
- data/app/models/proctolog/difference.rb +138 -0
- data/app/models/proctolog/doctor.rb +127 -0
- data/app/models/proctolog/evidence.rb +29 -0
- data/app/models/proctolog/failure.rb +7 -0
- data/app/models/proctolog/focus.rb +48 -0
- data/app/models/proctolog/fresh_sitting.rb +35 -0
- data/app/models/proctolog/github.rb +353 -0
- data/app/models/proctolog/handover/brief.rb +138 -0
- data/app/models/proctolog/handover/layers.rb +128 -0
- data/app/models/proctolog/handover/places.rb +98 -0
- data/app/models/proctolog/handover/taken.rb +41 -0
- data/app/models/proctolog/handover/worklist.rb +35 -0
- data/app/models/proctolog/handover.rb +123 -0
- data/app/models/proctolog/housekeeping.rb +23 -0
- data/app/models/proctolog/kit.rb +36 -0
- data/app/models/proctolog/marks.rb +130 -0
- data/app/models/proctolog/membership.rb +91 -0
- data/app/models/proctolog/mentions.rb +117 -0
- data/app/models/proctolog/note.rb +94 -0
- data/app/models/proctolog/outside.rb +45 -0
- data/app/models/proctolog/overlay/brief.rb +52 -0
- data/app/models/proctolog/overlay/written.rb +33 -0
- data/app/models/proctolog/overlay.rb +87 -0
- data/app/models/proctolog/owner.rb +16 -0
- data/app/models/proctolog/pick.rb +47 -0
- data/app/models/proctolog/picture.rb +138 -0
- data/app/models/proctolog/pieces_rendering.rb +40 -0
- data/app/models/proctolog/project.rb +118 -0
- data/app/models/proctolog/pull_request.rb +89 -0
- data/app/models/proctolog/recording.rb +194 -0
- data/app/models/proctolog/release.rb +59 -0
- data/app/models/proctolog/rendering.rb +303 -0
- data/app/models/proctolog/reports.rb +66 -0
- data/app/models/proctolog/response_files.rb +55 -0
- data/app/models/proctolog/rounds.rb +305 -0
- data/app/models/proctolog/route.rb +151 -0
- data/app/models/proctolog/rules.rb +121 -0
- data/app/models/proctolog/scenario.rb +126 -0
- data/app/models/proctolog/server.rb +273 -0
- data/app/models/proctolog/since.rb +108 -0
- data/app/models/proctolog/size.rb +30 -0
- data/app/models/proctolog/standing.rb +132 -0
- data/app/models/proctolog/theme.rb +40 -0
- data/app/models/proctolog/unopened/numbers.rb +48 -0
- data/app/models/proctolog/unopened/opening.rb +88 -0
- data/app/models/proctolog/unopened.rb +203 -0
- data/app/models/proctolog/visit.rb +26 -0
- data/app/views/layouts/proctolog/application.html.erb +58 -0
- data/app/views/proctolog/acceptances/new.html.erb +34 -0
- data/app/views/proctolog/bursts/_line.html.erb +28 -0
- data/app/views/proctolog/bursts/_merged.html.erb +23 -0
- data/app/views/proctolog/bursts/_moved.html.erb +16 -0
- data/app/views/proctolog/bursts/_notices.html.erb +39 -0
- data/app/views/proctolog/bursts/_put_away.html.erb +11 -0
- data/app/views/proctolog/bursts/_rules.html.erb +27 -0
- data/app/views/proctolog/bursts/_side.html.erb +49 -0
- data/app/views/proctolog/bursts/_side_stop.html.erb +10 -0
- data/app/views/proctolog/bursts/show.html.erb +54 -0
- data/app/views/proctolog/code_reviews/show.html.erb +33 -0
- data/app/views/proctolog/decisions/_alternative.html.erb +5 -0
- data/app/views/proctolog/decisions/_beneath.html.erb +21 -0
- data/app/views/proctolog/decisions/_box.html.erb +27 -0
- data/app/views/proctolog/decisions/_choices.html.erb +9 -0
- data/app/views/proctolog/decisions/_draft_tools.html.erb +47 -0
- data/app/views/proctolog/decisions/_part.html.erb +61 -0
- data/app/views/proctolog/decisions/_round_data.html.erb +1 -0
- data/app/views/proctolog/decisions/_theme.html.erb +13 -0
- data/app/views/proctolog/decisions/index.html.erb +75 -0
- data/app/views/proctolog/notes/_card.html.erb +19 -0
- data/app/views/proctolog/notes/_template.html.erb +41 -0
- data/app/views/proctolog/open_pull_requests/_side.html.erb +30 -0
- data/app/views/proctolog/open_pull_requests/index.html.erb +106 -0
- data/app/views/proctolog/prompts/show.html.erb +41 -0
- data/app/views/proctolog/pull_requests/_connections.html.erb +65 -0
- data/app/views/proctolog/pull_requests/_finish.html.erb +42 -0
- data/app/views/proctolog/pull_requests/_margin.html.erb +33 -0
- data/app/views/proctolog/pull_requests/_note.html.erb +4 -0
- data/app/views/proctolog/pull_requests/_outline.html.erb +26 -0
- data/app/views/proctolog/pull_requests/_paragraphs.html.erb +9 -0
- data/app/views/proctolog/pull_requests/_scenarios.html.erb +16 -0
- data/app/views/proctolog/pull_requests/_since.html.erb +42 -0
- data/app/views/proctolog/pull_requests/_standing.html.erb +51 -0
- data/app/views/proctolog/pull_requests/_status.html.erb +5 -0
- data/app/views/proctolog/pull_requests/_try.html.erb +122 -0
- data/app/views/proctolog/pull_requests/show.html.erb +145 -0
- data/app/views/proctolog/recordings/_dialog.html.erb +26 -0
- data/app/views/proctolog/recordings/_list.html.erb +8 -0
- data/app/views/proctolog/recordings/_note.html.erb +22 -0
- data/app/views/proctolog/recordings/index.html.erb +33 -0
- data/app/views/proctolog/recordings/show.html.erb +118 -0
- data/app/views/proctolog/scenarios/_dialog.html.erb +22 -0
- data/app/views/proctolog/scenarios/_note.html.erb +18 -0
- data/app/views/proctolog/scenarios/_open.html.erb +9 -0
- data/app/views/proctolog/scenarios/_scenario.html.erb +48 -0
- data/app/views/proctolog/scenarios/_server.html.erb +20 -0
- data/app/views/proctolog/scenarios/index.html.erb +62 -0
- data/bin/proctolog +330 -0
- data/completions/_proctolog +143 -0
- data/config/application.rb +94 -0
- data/config/database.yml +16 -0
- data/config/environment.rb +6 -0
- data/config/initializers/grouse.rb +113 -0
- data/config/initializers/grouse_notes.rb +9 -0
- data/config/keys.yml +6 -0
- data/config/routes.rb +71 -0
- data/config/storage.yml +4 -0
- data/db/migrate/20261019213542_create_grouse_tables.grouse.rb +128 -0
- data/db/migrate/20261019213550_create_active_storage_tables.active_storage.rb +58 -0
- data/docs/manual/app.md +157 -0
- data/docs/manual/bursts.md +129 -0
- data/docs/manual/deciding.md +89 -0
- data/docs/manual/desk.png +0 -0
- data/docs/manual/films.md +20 -0
- data/docs/manual/handover.md +93 -0
- data/docs/manual/pr-format.md +75 -0
- data/docs/manual/reading.md +233 -0
- data/docs/manual/unopened.md +80 -0
- data/docs/manual/updates.md +55 -0
- data/lib/proctolog/installation.rb +125 -0
- data/lib/proctolog/version.rb +7 -0
- data/public/asking.js +50 -0
- data/public/code-review.js +40 -0
- data/public/cursor.js +292 -0
- data/public/decision-screenshots.js +97 -0
- data/public/decisions.css +949 -0
- data/public/decisions.js +1289 -0
- data/public/desk.css +582 -0
- data/public/desk.js +126 -0
- data/public/favicon.png +0 -0
- data/public/keys.js +228 -0
- data/public/mentions.js +389 -0
- data/public/messages.js +103 -0
- data/public/notes.js +731 -0
- data/public/pick.css +99 -0
- data/public/plaque.png +0 -0
- data/public/pr-number.js +11 -0
- data/public/prompt.css +46 -0
- data/public/prompt.js +16 -0
- data/public/pull_request.css +805 -0
- data/public/recording.css +379 -0
- data/public/recording.js +394 -0
- data/public/release.js +25 -0
- data/public/respond.css +260 -0
- data/public/respond.js +95 -0
- data/public/review-drafts.js +949 -0
- data/public/review-rounds.js +1447 -0
- data/public/scenarios.css +253 -0
- data/public/scenarios.js +36 -0
- data/public/schemas/proctolog-response-v1.json +101 -0
- data/public/side.js +133 -0
- data/public/standing.css +478 -0
- data/public/terminal-apl.svg +6 -0
- data/public/terminal-cuneiform.svg +3 -0
- data/public/terminal-grain-dots.svg +6 -0
- data/public/terminal-grain-weave.svg +6 -0
- data/public/terminal-grain-wedges.svg +6 -0
- data/public/terminal-shades.svg +3 -0
- data/public/terminal.css +4426 -0
- data/public/toast.js +26 -0
- data/public/try.js +124 -0
- data/public/viewer.js +808 -0
- data/public/words.js +81 -0
- data/skills/proctolog-handover/SKILL.md +77 -0
- data/skills/proctolog-pr-writing/SKILL.md +228 -0
- data/skills/proctolog-pr-writing/references/example-description.md +33 -0
- metadata +366 -0
data/public/viewer.js
ADDED
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
// A picture looked at closely: it opens over the page at the size that fits,
|
|
2
|
+
// and from there the wheel, a pinch, + and - bring it nearer and farther, a
|
|
3
|
+
// drag or the arrows move it, 0 fits it again and 1 shows it at its own size.
|
|
4
|
+
// A click marks a point and Shift with a drag marks a region, and the note on
|
|
5
|
+
// it is written right there: the box stays on the picture, the page that
|
|
6
|
+
// opened the viewer writes its form beside it, and the viewer stays as it was,
|
|
7
|
+
// zoom and all, for the next. The notes the picture has are drawn on it, and
|
|
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.
|
|
10
|
+
//
|
|
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.
|
|
17
|
+
|
|
18
|
+
const NEAREST = 12;
|
|
19
|
+
const DRAG = 4;
|
|
20
|
+
// Between the halves side by side, in the pictures' own pixels.
|
|
21
|
+
const GAP = 16;
|
|
22
|
+
|
|
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
|
+
};
|
|
30
|
+
|
|
31
|
+
let dialog;
|
|
32
|
+
let stage;
|
|
33
|
+
let canvas;
|
|
34
|
+
let first;
|
|
35
|
+
let second;
|
|
36
|
+
let divider;
|
|
37
|
+
let slider;
|
|
38
|
+
let outlines;
|
|
39
|
+
let caption;
|
|
40
|
+
let keyRow;
|
|
41
|
+
let closer;
|
|
42
|
+
let marking;
|
|
43
|
+
// The notes drawn on the picture, and beside the one being written, its form.
|
|
44
|
+
let notes;
|
|
45
|
+
let beside;
|
|
46
|
+
let pictures = [];
|
|
47
|
+
let at = 0;
|
|
48
|
+
// What the page does with a place marked, and once the viewer closes.
|
|
49
|
+
let annotate;
|
|
50
|
+
let whenClosed;
|
|
51
|
+
// The place a note is being written on, by its half and its box, and the
|
|
52
|
+
// note it is when it was written before; the notes written here since the
|
|
53
|
+
// viewer opened, which the page shows once it closes; and those withdrawn here.
|
|
54
|
+
let draft;
|
|
55
|
+
let written = new Map();
|
|
56
|
+
let withdrawn = new Set();
|
|
57
|
+
// The picture's own size, and where it is drawn: its scale, and its corner on the stage.
|
|
58
|
+
let view = { scale: 1, x: 0, y: 0 };
|
|
59
|
+
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.
|
|
62
|
+
let mode = "side";
|
|
63
|
+
let showing = "after";
|
|
64
|
+
let divide = 0.5;
|
|
65
|
+
let changes;
|
|
66
|
+
let change = -1;
|
|
67
|
+
const compared = new Map();
|
|
68
|
+
|
|
69
|
+
const made = (tag, className, text) =>
|
|
70
|
+
Object.assign(document.createElement(tag), {
|
|
71
|
+
className,
|
|
72
|
+
...(text === undefined ? {} : { textContent: text }),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const build = () => {
|
|
76
|
+
dialog = made("dialog", "viewer");
|
|
77
|
+
dialog.dataset.desk = "";
|
|
78
|
+
dialog.tabIndex = -1;
|
|
79
|
+
dialog.setAttribute("aria-label", "Picture");
|
|
80
|
+
stage = made("div", "stage");
|
|
81
|
+
canvas = made("div", "canvas");
|
|
82
|
+
first = made("img", "first");
|
|
83
|
+
second = made("img", "second");
|
|
84
|
+
for (const image of [first, second]) image.draggable = false;
|
|
85
|
+
divider = made("span", "divider");
|
|
86
|
+
outlines = made("div", "outlines");
|
|
87
|
+
notes = made("div", "notes");
|
|
88
|
+
canvas.append(first, second, divider, outlines, notes);
|
|
89
|
+
marking = made("span", "marking");
|
|
90
|
+
marking.hidden = true;
|
|
91
|
+
beside = made("div", "beside-note");
|
|
92
|
+
beside.hidden = true;
|
|
93
|
+
stage.append(canvas, marking, beside);
|
|
94
|
+
slider = Object.assign(made("input", "swipe"), {
|
|
95
|
+
type: "range",
|
|
96
|
+
min: 0,
|
|
97
|
+
max: 100,
|
|
98
|
+
value: 50,
|
|
99
|
+
});
|
|
100
|
+
slider.setAttribute("aria-label", "Where the after picture begins");
|
|
101
|
+
slider.hidden = true;
|
|
102
|
+
caption = made("p", "caption");
|
|
103
|
+
keyRow = made("p", "keys");
|
|
104
|
+
// The way out for the pointer, where windows keep theirs; Esc is the key's.
|
|
105
|
+
closer = made("button", "close", "✕");
|
|
106
|
+
closer.type = "button";
|
|
107
|
+
closer.title = "Close (Esc)";
|
|
108
|
+
closer.setAttribute("aria-label", "Close the picture");
|
|
109
|
+
closer.addEventListener("click", () => dialog.close());
|
|
110
|
+
dialog.append(stage, slider, caption, keyRow, closer);
|
|
111
|
+
document.body.append(dialog);
|
|
112
|
+
listen();
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const pair = () => !!pictures[at]?.pair;
|
|
116
|
+
const halvesOf = (picture) => picture?.pair ?? (picture ? [picture] : []);
|
|
117
|
+
|
|
118
|
+
// The pictures as laid on the canvas, in their own pixels: one alone, or a
|
|
119
|
+
// pair's halves side by side or one over the other, from the top left.
|
|
120
|
+
const layout = () => {
|
|
121
|
+
const size = (image) => ({
|
|
122
|
+
width: image.naturalWidth,
|
|
123
|
+
height: image.naturalHeight,
|
|
124
|
+
});
|
|
125
|
+
const one = size(first);
|
|
126
|
+
if (!pair()) return { ...one, halves: [{ image: first, x: 0 }] };
|
|
127
|
+
const other = size(second);
|
|
128
|
+
const height = Math.max(one.height, other.height);
|
|
129
|
+
if (mode === "side")
|
|
130
|
+
return {
|
|
131
|
+
width: one.width + GAP + other.width,
|
|
132
|
+
height,
|
|
133
|
+
halves: [
|
|
134
|
+
{ image: first, x: 0 },
|
|
135
|
+
{ image: second, x: one.width + GAP },
|
|
136
|
+
],
|
|
137
|
+
};
|
|
138
|
+
return {
|
|
139
|
+
width: Math.max(one.width, other.width),
|
|
140
|
+
height,
|
|
141
|
+
halves: [
|
|
142
|
+
{ image: first, x: 0 },
|
|
143
|
+
{ image: second, x: 0 },
|
|
144
|
+
],
|
|
145
|
+
};
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const draw = () => {
|
|
149
|
+
const laid = layout();
|
|
150
|
+
canvas.dataset.view = pair() ? mode : "";
|
|
151
|
+
Object.assign(canvas.style, {
|
|
152
|
+
width: `${laid.width}px`,
|
|
153
|
+
height: `${laid.height}px`,
|
|
154
|
+
transform: `translate(${view.x}px, ${view.y}px) scale(${view.scale})`,
|
|
155
|
+
});
|
|
156
|
+
for (const { image, x } of laid.halves) image.style.left = `${x}px`;
|
|
157
|
+
second.hidden = !pair() || (mode === "blink" && showing === "before");
|
|
158
|
+
first.hidden = pair() && mode === "blink" && showing === "after";
|
|
159
|
+
second.style.clipPath =
|
|
160
|
+
pair() && mode === "swipe" ? `inset(0 0 0 ${divide * laid.width}px)` : "";
|
|
161
|
+
divider.hidden = !pair() || mode !== "swipe";
|
|
162
|
+
divider.style.left = `${divide * laid.width}px`;
|
|
163
|
+
// Lines as thin on the screen whatever the zoom.
|
|
164
|
+
canvas.style.setProperty("--line", `${1 / view.scale}px`);
|
|
165
|
+
slider.hidden = divider.hidden;
|
|
166
|
+
slider.value = String(Math.round(divide * 100));
|
|
167
|
+
// A divider put away with the focus on it gives the keys back to the viewer.
|
|
168
|
+
if (slider.hidden && document.activeElement === slider)
|
|
169
|
+
dialog.focus({ preventScroll: true });
|
|
170
|
+
outline(laid);
|
|
171
|
+
drawNotes(laid);
|
|
172
|
+
stage.dataset.zoomed = String(view.scale > fitted * 1.01);
|
|
173
|
+
};
|
|
174
|
+
|
|
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]) =>
|
|
177
|
+
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`,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// A note's box as the page draws it on a picture, in percent of it.
|
|
185
|
+
const boxOf = (region) =>
|
|
186
|
+
["left", "top", "width", "height"].map(
|
|
187
|
+
(side) => (Number.parseFloat(region.style[side]) || 0) / 100,
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
// The notes on a half, by their id and box: those the page has and those
|
|
191
|
+
// written here, less those withdrawn here.
|
|
192
|
+
const notesOn = (figure) =>
|
|
193
|
+
[
|
|
194
|
+
...[...(figure?.querySelectorAll(":scope > .region") ?? [])].map(
|
|
195
|
+
(region) => ({ id: region.dataset.note, box: boxOf(region) }),
|
|
196
|
+
),
|
|
197
|
+
...(written.get(figure) ?? []),
|
|
198
|
+
].filter(({ id }) => !withdrawn.has(id));
|
|
199
|
+
|
|
200
|
+
// The notes on each half showing, and the place a note is being written on,
|
|
201
|
+
// in place of the note it is when it was written before; a pin is a box of
|
|
202
|
+
// no size.
|
|
203
|
+
const drawNotes = (laid) => {
|
|
204
|
+
notes.replaceChildren();
|
|
205
|
+
const halves = halvesOf(pictures[at]);
|
|
206
|
+
laid.halves.forEach((half, index) => {
|
|
207
|
+
if (half.image.hidden) return;
|
|
208
|
+
for (const { id, box } of notesOn(halves[index]?.figure))
|
|
209
|
+
if (id !== draft?.note) notes.append(noteBox(half, box, { id, index }));
|
|
210
|
+
if (draft?.half === index)
|
|
211
|
+
notes.append(noteBox(half, draft.box, { drafted: true }));
|
|
212
|
+
});
|
|
213
|
+
placeBeside();
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const noteBox = (half, box, { id, index, drafted = false }) => {
|
|
217
|
+
const pin = !box[2] && !box[3];
|
|
218
|
+
const element = made(
|
|
219
|
+
"span",
|
|
220
|
+
["note", ...(pin ? ["pin"] : []), ...(drafted ? ["drafted"] : [])].join(" "),
|
|
221
|
+
);
|
|
222
|
+
laidOn(element, half, box);
|
|
223
|
+
// A pin is as large on the screen at any zoom: its size is the stylesheet's.
|
|
224
|
+
if (pin) Object.assign(element.style, { width: "", height: "" });
|
|
225
|
+
// A note saved is opened by a click on it.
|
|
226
|
+
if (id) Object.assign(element.dataset, { note: id, half: index, box });
|
|
227
|
+
return element;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
// Whether the picture showing has a note that a click opens, besides the
|
|
231
|
+
// one open.
|
|
232
|
+
const openable = () =>
|
|
233
|
+
markable() &&
|
|
234
|
+
halvesOf(pictures[at]).some(({ figure }) =>
|
|
235
|
+
notesOn(figure).some(({ id }) => id && id !== draft?.note),
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
// The form stands beside the box it is about, on whichever side has room,
|
|
239
|
+
// and moves with it as the picture does.
|
|
240
|
+
const GUTTER = 12;
|
|
241
|
+
const placeBeside = () => {
|
|
242
|
+
beside.hidden = !draft;
|
|
243
|
+
const box = notes.querySelector(".drafted");
|
|
244
|
+
if (!draft || !box) return;
|
|
245
|
+
const room = stage.getBoundingClientRect();
|
|
246
|
+
const drawn = box.getBoundingClientRect();
|
|
247
|
+
const { width, height } = beside.getBoundingClientRect();
|
|
248
|
+
const right = drawn.right - room.left + GUTTER;
|
|
249
|
+
const left = drawn.left - room.left - GUTTER - width;
|
|
250
|
+
const x =
|
|
251
|
+
right + width <= room.width
|
|
252
|
+
? right
|
|
253
|
+
: left >= 0
|
|
254
|
+
? left
|
|
255
|
+
: room.width - width - GUTTER;
|
|
256
|
+
const y = Math.min(
|
|
257
|
+
Math.max(drawn.top - room.top, GUTTER),
|
|
258
|
+
room.height - height - GUTTER,
|
|
259
|
+
);
|
|
260
|
+
Object.assign(beside.style, { left: `${x}px`, top: `${Math.max(y, 0)}px` });
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
// Each changed region outlined, on each half it shows on.
|
|
264
|
+
const outline = (laid) => {
|
|
265
|
+
outlines.replaceChildren();
|
|
266
|
+
if (!pair() || !changes?.boxes.length) return;
|
|
267
|
+
const offsets = [...new Set(laid.halves.map(({ x }) => x))];
|
|
268
|
+
changes.boxes.forEach((box, index) => {
|
|
269
|
+
for (const x of offsets) {
|
|
270
|
+
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
|
+
});
|
|
278
|
+
outlines.append(line);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
// At the size that shows all of it, never larger than it is, in the middle.
|
|
284
|
+
const fit = () => {
|
|
285
|
+
const room = stage.getBoundingClientRect();
|
|
286
|
+
const { width, height } = layout();
|
|
287
|
+
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
|
+
};
|
|
294
|
+
draw();
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
// Nearer or farther by a factor, holding still the point of the stage given.
|
|
298
|
+
const zoom = (factor, point) => {
|
|
299
|
+
const room = stage.getBoundingClientRect();
|
|
300
|
+
const hold = point ?? { x: room.width / 2, y: room.height / 2 };
|
|
301
|
+
const scale = Math.min(
|
|
302
|
+
Math.max(view.scale * factor, fitted / 2),
|
|
303
|
+
Math.max(NEAREST, fitted),
|
|
304
|
+
);
|
|
305
|
+
const by = scale / view.scale;
|
|
306
|
+
view = {
|
|
307
|
+
scale,
|
|
308
|
+
x: hold.x - (hold.x - view.x) * by,
|
|
309
|
+
y: hold.y - (hold.y - view.y) * by,
|
|
310
|
+
};
|
|
311
|
+
draw();
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const move = (x, y) => {
|
|
315
|
+
view = { ...view, x: view.x + x, y: view.y + y };
|
|
316
|
+
draw();
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const onStage = (event) => {
|
|
320
|
+
const room = stage.getBoundingClientRect();
|
|
321
|
+
return { x: event.clientX - room.left, y: event.clientY - room.top };
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
// Whether a mark can be made in the view the picture is looked at in: not
|
|
325
|
+
// where a pair's halves are seen together, nor on a picture that takes no note.
|
|
326
|
+
const markable = () =>
|
|
327
|
+
!!annotate && (!pair() || mode === "side" || mode === "blink");
|
|
328
|
+
|
|
329
|
+
// The half a mark made at a point of the stage goes on: the picture alone,
|
|
330
|
+
// side by side the half the point is over (across the gap, the nearer), and
|
|
331
|
+
// in blink the one showing.
|
|
332
|
+
const halfAt = (point) => {
|
|
333
|
+
if (!pair()) return 0;
|
|
334
|
+
if (mode === "blink") return showing === "after" ? 1 : 0;
|
|
335
|
+
const between = (layout().halves[1].x - GAP / 2) * view.scale + view.x;
|
|
336
|
+
return point.x < between ? 0 : 1;
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
// A point of the stage held within a half as it is drawn there.
|
|
340
|
+
const heldIn = (point, half) => {
|
|
341
|
+
const { image, x } = layout().halves[half];
|
|
342
|
+
const left = view.x + x * view.scale;
|
|
343
|
+
return {
|
|
344
|
+
x: Math.min(
|
|
345
|
+
Math.max(point.x, left),
|
|
346
|
+
left + image.naturalWidth * view.scale,
|
|
347
|
+
),
|
|
348
|
+
y: Math.min(
|
|
349
|
+
Math.max(point.y, view.y),
|
|
350
|
+
view.y + image.naturalHeight * view.scale,
|
|
351
|
+
),
|
|
352
|
+
};
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
// A place of the stage as a fraction of the half marked, held within it.
|
|
356
|
+
const within = (fraction) => Math.min(Math.max(fraction, 0), 1);
|
|
357
|
+
const onPicture = (point, half) => {
|
|
358
|
+
const { image, x } = layout().halves[half];
|
|
359
|
+
return {
|
|
360
|
+
x: within(
|
|
361
|
+
(point.x - view.x - x * view.scale) / (image.naturalWidth * view.scale),
|
|
362
|
+
),
|
|
363
|
+
y: within((point.y - view.y) / (image.naturalHeight * view.scale)),
|
|
364
|
+
};
|
|
365
|
+
};
|
|
366
|
+
|
|
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];
|
|
394
|
+
caption.textContent = [
|
|
395
|
+
pictures.length > 1 ? `${at + 1} of ${pictures.length}` : "",
|
|
396
|
+
picture.name ?? "",
|
|
397
|
+
...(pair() ? comparing() : []),
|
|
398
|
+
]
|
|
399
|
+
.filter(Boolean)
|
|
400
|
+
.join(" · ");
|
|
401
|
+
keys();
|
|
402
|
+
};
|
|
403
|
+
|
|
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)),
|
|
408
|
+
` ${does}`,
|
|
409
|
+
);
|
|
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")]),
|
|
427
|
+
);
|
|
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"),
|
|
453
|
+
);
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const compare = async (picture) => {
|
|
457
|
+
const file = (src) => new URL(src, location.href).pathname.split("/").pop();
|
|
458
|
+
const address =
|
|
459
|
+
picture.compare ??
|
|
460
|
+
`/pictures/${picture.pair.map(({ src }) => file(src)).join("/against/")}`;
|
|
461
|
+
if (!compared.has(address))
|
|
462
|
+
compared.set(
|
|
463
|
+
address,
|
|
464
|
+
fetch(address, { headers: { Accept: "application/json" } }).then(
|
|
465
|
+
(response) =>
|
|
466
|
+
response.ok
|
|
467
|
+
? response.json()
|
|
468
|
+
: { compared: false, boxes: [], sizes: [] },
|
|
469
|
+
() => ({ compared: false, boxes: [], sizes: [] }),
|
|
470
|
+
),
|
|
471
|
+
);
|
|
472
|
+
const found = await compared.get(address);
|
|
473
|
+
if (pictures[at] !== picture) return;
|
|
474
|
+
changes = found;
|
|
475
|
+
draw();
|
|
476
|
+
say();
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
const show = (index) => {
|
|
480
|
+
putAway();
|
|
481
|
+
at = (index + pictures.length) % pictures.length;
|
|
482
|
+
const picture = pictures[at];
|
|
483
|
+
changes = undefined;
|
|
484
|
+
change = -1;
|
|
485
|
+
const halves = halvesOf(picture);
|
|
486
|
+
first.src = halves[0].src;
|
|
487
|
+
first.alt = halves[0].alt ?? "";
|
|
488
|
+
if (picture.pair) {
|
|
489
|
+
second.src = halves[1].src;
|
|
490
|
+
second.alt = halves[1].alt ?? "";
|
|
491
|
+
compare(picture);
|
|
492
|
+
} else second.removeAttribute("src");
|
|
493
|
+
say();
|
|
494
|
+
// Fitted once, when it is known how large it is: the late load of a
|
|
495
|
+
// picture already at hand must not undo a zoom made since.
|
|
496
|
+
const shown = picture;
|
|
497
|
+
Promise.all(
|
|
498
|
+
[first, ...(picture.pair ? [second] : [])].map((image) =>
|
|
499
|
+
image.decode().catch(() => {}),
|
|
500
|
+
),
|
|
501
|
+
).then(() => pictures[at] === shown && dialog.open && fit());
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
// Looks at a pair another way, holding the zoom when the layout stays.
|
|
505
|
+
const look = (name) => {
|
|
506
|
+
if (!pair()) return;
|
|
507
|
+
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();
|
|
515
|
+
say();
|
|
516
|
+
};
|
|
517
|
+
|
|
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;
|
|
525
|
+
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),
|
|
531
|
+
);
|
|
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
|
+
};
|
|
537
|
+
draw();
|
|
538
|
+
say();
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
// Whether the form open holds words not saved: a note written before is
|
|
542
|
+
// measured against what it said.
|
|
543
|
+
const unsaved = () => {
|
|
544
|
+
const words = beside.querySelector("textarea");
|
|
545
|
+
return !!words && words.value !== words.defaultValue;
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
// A place marked: its box stays on the picture, and the page writes the
|
|
549
|
+
// note's form beside it. Marking again while the form of a new note is open
|
|
550
|
+
// moves the place, and keeps the words typed; a note written before keeps its
|
|
551
|
+
// place, and its form gives way to the new one only once nothing in it
|
|
552
|
+
// waits to be saved.
|
|
553
|
+
const mark = (half, box) => {
|
|
554
|
+
if (draft?.note && unsaved()) return;
|
|
555
|
+
if (draft?.note) putAway();
|
|
556
|
+
write(half, box);
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
// A note drawn on the picture, opened beside it with its words; from the
|
|
560
|
+
// form of another, only once nothing in that one waits to be saved.
|
|
561
|
+
const open = (element) => {
|
|
562
|
+
const { note, half, box } = element.dataset;
|
|
563
|
+
if (draft?.note === note || unsaved()) return;
|
|
564
|
+
putAway();
|
|
565
|
+
write(Number(half), box.split(",").map(Number), note);
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
const write = (half, box, note) => {
|
|
569
|
+
const picture = pictures[at];
|
|
570
|
+
const marked = { ...picture, ...halvesOf(picture)[half] };
|
|
571
|
+
// Shown before the page writes in it, so that the form can take the focus.
|
|
572
|
+
beside.hidden = false;
|
|
573
|
+
if (!annotate?.(marked, box, beside, note)) {
|
|
574
|
+
beside.hidden = !draft;
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
draft = { half, box, figure: marked.figure, note };
|
|
578
|
+
draw();
|
|
579
|
+
say();
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
// The form put away, the note saved or not: the viewer is as it was.
|
|
583
|
+
const putAway = () => {
|
|
584
|
+
if (!draft) return;
|
|
585
|
+
draft = undefined;
|
|
586
|
+
beside.replaceChildren();
|
|
587
|
+
draw();
|
|
588
|
+
say();
|
|
589
|
+
dialog.focus({ preventScroll: true });
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
// A note saved stays drawn on its picture while the viewer is open, and one
|
|
593
|
+
// withdrawn is no longer drawn.
|
|
594
|
+
const kept = ({ detail: { id, withdrawn: gone } }) => {
|
|
595
|
+
const { figure, box, note } = draft;
|
|
596
|
+
if (gone) withdrawn.add(id);
|
|
597
|
+
else if (!note) written.set(figure, [...(written.get(figure) ?? []), { id, box }]);
|
|
598
|
+
putAway();
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
const swipeTo = (point) => {
|
|
602
|
+
const { width } = layout();
|
|
603
|
+
divide = within((point.x - view.x) / (width * view.scale));
|
|
604
|
+
draw();
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
const listen = () => {
|
|
608
|
+
// Fingers on the stage: one drags, two pinch.
|
|
609
|
+
const touching = new Map();
|
|
610
|
+
let drag;
|
|
611
|
+
|
|
612
|
+
stage.addEventListener(
|
|
613
|
+
"wheel",
|
|
614
|
+
(event) => {
|
|
615
|
+
// The note's form scrolls as a form does.
|
|
616
|
+
if (beside.contains(event.target)) return;
|
|
617
|
+
event.preventDefault();
|
|
618
|
+
// A pinch on a trackpad comes as a wheel with Ctrl, in finer steps.
|
|
619
|
+
const step = event.ctrlKey ? 0.012 : 0.0018;
|
|
620
|
+
zoom(Math.exp(-event.deltaY * step), onStage(event));
|
|
621
|
+
},
|
|
622
|
+
{ passive: false },
|
|
623
|
+
);
|
|
624
|
+
|
|
625
|
+
stage.addEventListener("pointerdown", (event) => {
|
|
626
|
+
if (event.button !== 0 || beside.contains(event.target)) return;
|
|
627
|
+
stage.setPointerCapture(event.pointerId);
|
|
628
|
+
touching.set(event.pointerId, onStage(event));
|
|
629
|
+
const from = onStage(event);
|
|
630
|
+
drag = {
|
|
631
|
+
from,
|
|
632
|
+
last: from,
|
|
633
|
+
moved: false,
|
|
634
|
+
// A note drawn on the picture that a click here opens.
|
|
635
|
+
note: markable() && event.target.closest(".note[data-note]"),
|
|
636
|
+
// The half a mark from here goes on, whichever way the drag then goes.
|
|
637
|
+
half: markable() ? halfAt(from) : undefined,
|
|
638
|
+
marking: event.shiftKey && markable(),
|
|
639
|
+
swiping: pair() && mode === "swipe",
|
|
640
|
+
};
|
|
641
|
+
if (drag.swiping) swipeTo(drag.from);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
stage.addEventListener("pointermove", (event) => {
|
|
645
|
+
if (!touching.has(event.pointerId)) return;
|
|
646
|
+
const now = onStage(event);
|
|
647
|
+
if (touching.size === 2) {
|
|
648
|
+
const [one, other] = [...touching.values()];
|
|
649
|
+
const before = Math.hypot(one.x - other.x, one.y - other.y);
|
|
650
|
+
touching.set(event.pointerId, now);
|
|
651
|
+
const [firstFinger, secondFinger] = [...touching.values()];
|
|
652
|
+
const after = Math.hypot(
|
|
653
|
+
firstFinger.x - secondFinger.x,
|
|
654
|
+
firstFinger.y - secondFinger.y,
|
|
655
|
+
);
|
|
656
|
+
if (before)
|
|
657
|
+
zoom(after / before, {
|
|
658
|
+
x: (firstFinger.x + secondFinger.x) / 2,
|
|
659
|
+
y: (firstFinger.y + secondFinger.y) / 2,
|
|
660
|
+
});
|
|
661
|
+
drag.moved = true;
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
touching.set(event.pointerId, now);
|
|
665
|
+
if (!drag) return;
|
|
666
|
+
if (Math.hypot(now.x - drag.from.x, now.y - drag.from.y) >= DRAG)
|
|
667
|
+
drag.moved = true;
|
|
668
|
+
if (drag.swiping) swipeTo(now);
|
|
669
|
+
else if (drag.marking) {
|
|
670
|
+
if (!drag.moved) return;
|
|
671
|
+
// 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
|
+
});
|
|
680
|
+
marking.hidden = false;
|
|
681
|
+
} else if (drag.moved) {
|
|
682
|
+
move(now.x - drag.last.x, now.y - drag.last.y);
|
|
683
|
+
}
|
|
684
|
+
drag.last = now;
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
const release = (event) => {
|
|
688
|
+
if (!touching.delete(event.pointerId) || !drag || touching.size) return;
|
|
689
|
+
const { from, moved, marking: drawn, swiping, half, note } = drag;
|
|
690
|
+
drag = undefined;
|
|
691
|
+
marking.hidden = true;
|
|
692
|
+
const here = onStage(event);
|
|
693
|
+
if (event.type === "pointercancel" || swiping || half === undefined) return;
|
|
694
|
+
if (!moved && note && !event.shiftKey) return open(note);
|
|
695
|
+
if (!moved) {
|
|
696
|
+
const point = onPicture(here, half);
|
|
697
|
+
return mark(half, [point.x, point.y, 0, 0]);
|
|
698
|
+
}
|
|
699
|
+
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
|
+
]);
|
|
708
|
+
};
|
|
709
|
+
stage.addEventListener("pointerup", release);
|
|
710
|
+
stage.addEventListener("pointercancel", release);
|
|
711
|
+
|
|
712
|
+
slider.addEventListener("input", () => {
|
|
713
|
+
divide = Number(slider.value) / 100;
|
|
714
|
+
draw();
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
const STEP = 80;
|
|
718
|
+
const actions = {
|
|
719
|
+
"+": () => zoom(1.25),
|
|
720
|
+
"=": () => zoom(1.25),
|
|
721
|
+
"-": () => zoom(0.8),
|
|
722
|
+
_: () => zoom(0.8),
|
|
723
|
+
0: fit,
|
|
724
|
+
1: () => zoom(1 / view.scale),
|
|
725
|
+
ArrowLeft: () => move(STEP, 0),
|
|
726
|
+
ArrowRight: () => move(-STEP, 0),
|
|
727
|
+
ArrowUp: () => move(0, STEP),
|
|
728
|
+
ArrowDown: () => move(0, -STEP),
|
|
729
|
+
"[": () => show(at - 1),
|
|
730
|
+
"]": () => show(at + 1),
|
|
731
|
+
" ": () => dialog.close(),
|
|
732
|
+
n: nextChange,
|
|
733
|
+
...Object.fromEntries(
|
|
734
|
+
Object.entries(VIEWS).map(([key, [name]]) => [key, () => look(name)]),
|
|
735
|
+
),
|
|
736
|
+
};
|
|
737
|
+
dialog.addEventListener("keydown", (event) => {
|
|
738
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
|
739
|
+
// The page under the viewer has keys of its own: none of these is for it.
|
|
740
|
+
event.stopPropagation();
|
|
741
|
+
// Escape puts the note's form away before it closes the viewer, and keeps
|
|
742
|
+
// it while it holds words not saved, as the page's form does. It is
|
|
743
|
+
// answered as a key: the browser lets a dialog refuse to close only so
|
|
744
|
+
// often.
|
|
745
|
+
if (event.key === "Escape" && draft) {
|
|
746
|
+
event.preventDefault();
|
|
747
|
+
if (!unsaved()) putAway();
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
// What is typed into the note is words, not keys; ⌘ Enter, which saves
|
|
751
|
+
// it, went to the page's form above.
|
|
752
|
+
if (beside.contains(event.target)) return;
|
|
753
|
+
// The divider, while it has the focus, moves on the arrows itself.
|
|
754
|
+
if (event.target === slider && event.key.startsWith("Arrow")) return;
|
|
755
|
+
if (!Object.hasOwn(actions, event.key)) return;
|
|
756
|
+
event.preventDefault();
|
|
757
|
+
actions[event.key]();
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
beside.addEventListener("closed", putAway);
|
|
761
|
+
beside.addEventListener("noted", kept);
|
|
762
|
+
|
|
763
|
+
addEventListener("resize", () => dialog.open && fit());
|
|
764
|
+
dialog.addEventListener("close", () => {
|
|
765
|
+
draft = undefined;
|
|
766
|
+
beside.replaceChildren();
|
|
767
|
+
beside.hidden = true;
|
|
768
|
+
// The browser gives the focus back as it closes; this event comes after,
|
|
769
|
+
// when the owner may already have moved on with the keys.
|
|
770
|
+
const at = document.activeElement;
|
|
771
|
+
if (!at || at === document.body || dialog.contains(at))
|
|
772
|
+
opener?.focus({ preventScroll: true });
|
|
773
|
+
whenClosed?.();
|
|
774
|
+
});
|
|
775
|
+
};
|
|
776
|
+
|
|
777
|
+
let opener;
|
|
778
|
+
|
|
779
|
+
// Opens on one of `shown`, each a picture by its `src`, `alt` and `name`, or
|
|
780
|
+
// a pair by its `name` and its two halves in `pair`, each a picture, and the
|
|
781
|
+
// address that compares them in `compare` when it is not the description's; with
|
|
782
|
+
// whatever else the caller keeps on them, the page's `figure` first. Once
|
|
783
|
+
// the owner marks a place, `note` is called with the picture marked (a
|
|
784
|
+
// pair's half carrying what its pair does), "x, y, width, height" in
|
|
785
|
+
// fractions of it, and the element beside the place to write the note's form
|
|
786
|
+
// in; it answers whether it did. A note drawn there and clicked is called for
|
|
787
|
+
// the same way, with its id after them; the page's own notes are the regions
|
|
788
|
+
// in a figure, by their `data-note`. The form says it is done by a "noted"
|
|
789
|
+
// event once the note is saved, with its `id` and whether it was `withdrawn`,
|
|
790
|
+
// or "closed" when it is put away. `closed` is called once the viewer is.
|
|
791
|
+
export const viewPictures = (shown, index = 0, { note, closed } = {}) => {
|
|
792
|
+
if (!shown.length) return;
|
|
793
|
+
if (!dialog) build();
|
|
794
|
+
pictures = shown;
|
|
795
|
+
annotate = note;
|
|
796
|
+
whenClosed = closed;
|
|
797
|
+
written = new Map();
|
|
798
|
+
withdrawn = new Set();
|
|
799
|
+
dialog.toggleAttribute("data-takes-notes", !!note);
|
|
800
|
+
opener = document.activeElement;
|
|
801
|
+
mode = "side";
|
|
802
|
+
showing = "after";
|
|
803
|
+
divide = 0.5;
|
|
804
|
+
dialog.showModal();
|
|
805
|
+
// Its keys are its own from the start, whatever opened it.
|
|
806
|
+
dialog.focus({ preventScroll: true });
|
|
807
|
+
show(index);
|
|
808
|
+
};
|