@ascribed/astro 0.1.0 → 0.1.2-next.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -2
- package/dist/attributes.d.ts +17 -0
- package/dist/attributes.js +93 -5
- package/dist/attributes.js.map +1 -1
- package/dist/dev.d.ts +24 -1
- package/dist/dev.js +69 -9
- package/dist/dev.js.map +1 -1
- package/dist/index.d.ts +13 -0
- package/dist/index.js +84 -9
- package/dist/index.js.map +1 -1
- package/dist/rehype.d.ts +1 -1
- package/dist/rehype.js +19 -5
- package/dist/rehype.js.map +1 -1
- package/dist/review/devfile.d.ts +21 -0
- package/dist/review/devfile.js +39 -0
- package/dist/review/devfile.js.map +1 -0
- package/dist/review/diff.d.ts +21 -0
- package/dist/review/diff.js +47 -0
- package/dist/review/diff.js.map +1 -0
- package/dist/review/github.d.ts +19 -0
- package/dist/review/github.js +73 -0
- package/dist/review/github.js.map +1 -0
- package/dist/review/protocol.d.ts +98 -0
- package/dist/review/protocol.js +14 -0
- package/dist/review/protocol.js.map +1 -0
- package/dist/review/routes.d.ts +13 -0
- package/dist/review/routes.js +92 -0
- package/dist/review/routes.js.map +1 -0
- package/dist/review/server.d.ts +100 -0
- package/dist/review/server.js +336 -0
- package/dist/review/server.js.map +1 -0
- package/dist/run.d.ts +14 -1
- package/dist/run.js +19 -3
- package/dist/run.js.map +1 -1
- package/dist/satteri.d.ts +1 -1
- package/dist/satteri.js +22 -5
- package/dist/satteri.js.map +1 -1
- package/dist/toolbar/app.d.ts +2 -0
- package/dist/toolbar/app.js +951 -0
- package/dist/toolbar/app.js.map +1 -0
- package/dist/toolbar/page.d.ts +40 -0
- package/dist/toolbar/page.js +120 -0
- package/dist/toolbar/page.js.map +1 -0
- package/dist/toolbar/style.d.ts +1 -0
- package/dist/toolbar/style.js +250 -0
- package/dist/toolbar/style.js.map +1 -0
- package/dist/toolbar/text.d.ts +49 -0
- package/dist/toolbar/text.js +114 -0
- package/dist/toolbar/text.js.map +1 -0
- package/package.json +6 -3
|
@@ -0,0 +1,951 @@
|
|
|
1
|
+
// The Ascribe review app in Astro's dev toolbar: the site preview's review.
|
|
2
|
+
// It marks the page's changes with @ascribed/review's marks, hosts the
|
|
3
|
+
// overlay for the pull request's threads, and holds the review controls in a
|
|
4
|
+
// panel that opens from its toolbar button. Every request goes to the dev
|
|
5
|
+
// server over the toolbar's channel; the page never sees a token and makes
|
|
6
|
+
// no request to GitHub.
|
|
7
|
+
//
|
|
8
|
+
// Review stays on across pages and reloads (the dev server holds it), and
|
|
9
|
+
// the panel stays as the reviewer left it in this tab.
|
|
10
|
+
import { clearMarks, describeSource, goTo, markChanges, setShow, showSources, } from "@ascribed/review/marks";
|
|
11
|
+
import marksCss from "@ascribed/review/marks.css?inline";
|
|
12
|
+
import { createOverlay, renderMarkdown, } from "@ascribed/review/overlay";
|
|
13
|
+
import { defineToolbarApp } from "astro/toolbar";
|
|
14
|
+
import { APP_ID, CHANGED_EVENT, REQUEST_EVENT, RESULT_EVENT, } from "../review/protocol.js";
|
|
15
|
+
import { anchorOf, anchoredBlocks, anchorsArrived, contentRoot, pageFile, pageScheme, sourceLocation, } from "./page.js";
|
|
16
|
+
import { PANEL_CSS } from "./style.js";
|
|
17
|
+
import { againstText, buttonLabel, countsText, errorsNotice, nextChangedPage, nextPageText, pageDetail, position, THREAD_HASH, threadLocation, threadsNotice, unsentText, } from "./text.js";
|
|
18
|
+
/** The width of the page's container from which threads show in a column beside it. */
|
|
19
|
+
const COLUMN_AT = 720;
|
|
20
|
+
/** The room between the panel and the window's edges, or the column of threads. */
|
|
21
|
+
const PANEL_MARGIN = 10;
|
|
22
|
+
const PANEL_WIDTH = 960;
|
|
23
|
+
/** How long a request may take before the app gives up on the dev server. */
|
|
24
|
+
const TIMEOUT_MS = 120_000;
|
|
25
|
+
/** This tab's memory: whether the panel is open, and a page to step into. */
|
|
26
|
+
const OPEN_KEY = "ascribe-review-open";
|
|
27
|
+
const FIRST_KEY = "ascribe-review-first";
|
|
28
|
+
const SHOWS = [
|
|
29
|
+
["changes", "Changes"],
|
|
30
|
+
["will", "As it will be"],
|
|
31
|
+
["was", "As it was"],
|
|
32
|
+
];
|
|
33
|
+
export default defineToolbarApp({
|
|
34
|
+
init(canvas, app, server) {
|
|
35
|
+
void new ReviewApp(canvas, app, server, document).init();
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
/** Requests to the dev server, each answered by its `id`. */
|
|
39
|
+
class Channel {
|
|
40
|
+
server;
|
|
41
|
+
seq = 0;
|
|
42
|
+
waiting = new Map();
|
|
43
|
+
tab = Math.random().toString(36).slice(2);
|
|
44
|
+
constructor(server) {
|
|
45
|
+
this.server = server;
|
|
46
|
+
server.on(RESULT_EVENT, (result) => {
|
|
47
|
+
if (result.tab !== this.tab)
|
|
48
|
+
return;
|
|
49
|
+
const waiter = this.waiting.get(result.id);
|
|
50
|
+
if (!waiter)
|
|
51
|
+
return;
|
|
52
|
+
this.waiting.delete(result.id);
|
|
53
|
+
if (result.error)
|
|
54
|
+
waiter.reject(result.error);
|
|
55
|
+
else
|
|
56
|
+
waiter.resolve(result.result);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
request(method, params = {}) {
|
|
60
|
+
const id = ++this.seq;
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const timer = setTimeout(() => {
|
|
63
|
+
this.waiting.delete(id);
|
|
64
|
+
reject({ message: "The dev server didn't answer. Is `astro dev` still running?" });
|
|
65
|
+
}, TIMEOUT_MS);
|
|
66
|
+
this.waiting.set(id, {
|
|
67
|
+
resolve: (value) => {
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
resolve(value);
|
|
70
|
+
},
|
|
71
|
+
reject: (error) => {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
reject(error);
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
this.server.send(REQUEST_EVENT, { tab: this.tab, id, method, params });
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
class ReviewApp {
|
|
81
|
+
app;
|
|
82
|
+
doc;
|
|
83
|
+
channel;
|
|
84
|
+
panel;
|
|
85
|
+
live;
|
|
86
|
+
stage = { kind: "off" };
|
|
87
|
+
open = false;
|
|
88
|
+
root;
|
|
89
|
+
/** Whether the page's anchors arrived: else everything is listed in the panel. */
|
|
90
|
+
arrived = true;
|
|
91
|
+
marked;
|
|
92
|
+
marks = [];
|
|
93
|
+
show = "changes";
|
|
94
|
+
at = -1;
|
|
95
|
+
atEnd = false;
|
|
96
|
+
breakdown = false;
|
|
97
|
+
overlay;
|
|
98
|
+
overlayFor;
|
|
99
|
+
listeners = [];
|
|
100
|
+
/** The threads, read for a page whose anchors didn't arrive. */
|
|
101
|
+
listed;
|
|
102
|
+
stopSources;
|
|
103
|
+
style;
|
|
104
|
+
padding;
|
|
105
|
+
message;
|
|
106
|
+
messageTimer;
|
|
107
|
+
/** The scheme the site set for review's colors itself, if it did. */
|
|
108
|
+
siteScheme;
|
|
109
|
+
restyleTimer;
|
|
110
|
+
constructor(canvas, app, server, doc) {
|
|
111
|
+
this.app = app;
|
|
112
|
+
this.doc = doc;
|
|
113
|
+
this.channel = new Channel(server);
|
|
114
|
+
const style = doc.createElement("style");
|
|
115
|
+
style.textContent = PANEL_CSS;
|
|
116
|
+
this.panel = doc.createElement("div");
|
|
117
|
+
this.panel.className = "panel";
|
|
118
|
+
this.panel.setAttribute("role", "region");
|
|
119
|
+
this.panel.setAttribute("aria-label", "Ascribe review");
|
|
120
|
+
this.live = doc.createElement("div");
|
|
121
|
+
this.live.className = "sr-only";
|
|
122
|
+
this.live.setAttribute("role", "status");
|
|
123
|
+
this.live.setAttribute("aria-live", "polite");
|
|
124
|
+
canvas.append(style, this.panel, this.live);
|
|
125
|
+
this.siteScheme = doc.documentElement.getAttribute("data-ascribe-scheme");
|
|
126
|
+
const win = doc.defaultView;
|
|
127
|
+
win
|
|
128
|
+
?.matchMedia?.("(prefers-color-scheme: dark)")
|
|
129
|
+
.addEventListener("change", () => this.render());
|
|
130
|
+
win?.addEventListener("resize", () => this.placePanel());
|
|
131
|
+
// A theme switch on the page (a class or `data-theme` on the root, say) changes its colors.
|
|
132
|
+
const restyle = () => {
|
|
133
|
+
this.applyScheme();
|
|
134
|
+
// Again once a transition between the themes is over.
|
|
135
|
+
clearTimeout(this.restyleTimer);
|
|
136
|
+
this.restyleTimer = setTimeout(() => this.applyScheme(), 400);
|
|
137
|
+
};
|
|
138
|
+
const observer = new MutationObserver((records) => {
|
|
139
|
+
if (records.some((r) => r.attributeName !== "data-ascribe-scheme"))
|
|
140
|
+
restyle();
|
|
141
|
+
});
|
|
142
|
+
for (const target of [doc.documentElement, doc.body]) {
|
|
143
|
+
observer.observe(target, { attributes: true });
|
|
144
|
+
}
|
|
145
|
+
this.panel.addEventListener("keydown", (event) => event.stopPropagation());
|
|
146
|
+
server.on(CHANGED_EVENT, (event) => {
|
|
147
|
+
if (event.from !== this.channel.tab)
|
|
148
|
+
void this.changedElsewhere();
|
|
149
|
+
});
|
|
150
|
+
app.onToggled(({ state }) => {
|
|
151
|
+
this.open = state;
|
|
152
|
+
remember(OPEN_KEY, state ? "1" : "0");
|
|
153
|
+
if (state && this.stage.kind === "off")
|
|
154
|
+
void this.start();
|
|
155
|
+
this.render();
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
async init() {
|
|
159
|
+
let status;
|
|
160
|
+
try {
|
|
161
|
+
status = await this.channel.request("status");
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (!status.on)
|
|
167
|
+
return;
|
|
168
|
+
await this.load();
|
|
169
|
+
if (recall(OPEN_KEY) === "1")
|
|
170
|
+
this.app.toggleState({ state: true });
|
|
171
|
+
}
|
|
172
|
+
// --- Review on and off ---
|
|
173
|
+
async start() {
|
|
174
|
+
this.stage = { kind: "starting" };
|
|
175
|
+
this.render();
|
|
176
|
+
try {
|
|
177
|
+
await this.channel.request("start", {});
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
this.stage = { kind: "failed", message: messageOf(error) };
|
|
181
|
+
this.render();
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
await this.load();
|
|
185
|
+
}
|
|
186
|
+
async stop() {
|
|
187
|
+
try {
|
|
188
|
+
await this.channel.request("stop");
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
this.notify(messageOf(error));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
this.clearPage();
|
|
195
|
+
this.stage = { kind: "off" };
|
|
196
|
+
this.render();
|
|
197
|
+
this.announce("Review stopped.");
|
|
198
|
+
}
|
|
199
|
+
/** Another page changed the threads, or stopped review. */
|
|
200
|
+
async changedElsewhere() {
|
|
201
|
+
let status;
|
|
202
|
+
try {
|
|
203
|
+
status = await this.channel.request("status");
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (!status.on) {
|
|
209
|
+
this.clearPage();
|
|
210
|
+
this.stage = { kind: "off" };
|
|
211
|
+
this.render();
|
|
212
|
+
}
|
|
213
|
+
else if (this.stage.kind === "on") {
|
|
214
|
+
this.didChange();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/** Reads the page's changes and threads from the dev server, and draws them. */
|
|
218
|
+
async load() {
|
|
219
|
+
const blocks = anchoredBlocks(this.doc);
|
|
220
|
+
let view;
|
|
221
|
+
try {
|
|
222
|
+
view = await this.channel.request("page", {
|
|
223
|
+
route: this.doc.location.pathname,
|
|
224
|
+
path: pageFile(blocks),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
this.stage = { kind: "failed", message: messageOf(error) };
|
|
229
|
+
this.render();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
this.stage = { kind: "on", view };
|
|
233
|
+
this.draw(view, blocks);
|
|
234
|
+
}
|
|
235
|
+
async refresh() {
|
|
236
|
+
this.notify("Refreshing…");
|
|
237
|
+
try {
|
|
238
|
+
await this.channel.request("refresh");
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
this.notify(messageOf(error));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
await this.load();
|
|
245
|
+
this.didChange();
|
|
246
|
+
this.notify("Refreshed.");
|
|
247
|
+
}
|
|
248
|
+
// --- The page ---
|
|
249
|
+
draw(view, blocks) {
|
|
250
|
+
if (view.kind === "not-page") {
|
|
251
|
+
this.clearPage();
|
|
252
|
+
this.render();
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const root = contentRoot(blocks);
|
|
256
|
+
const changes = view.page?.changes ?? [];
|
|
257
|
+
this.arrived = root !== undefined && anchorsArrived(root, blocks.length, changes);
|
|
258
|
+
if (!root || !this.arrived) {
|
|
259
|
+
this.clearPage();
|
|
260
|
+
this.render();
|
|
261
|
+
if (view.threads.state === "on")
|
|
262
|
+
void this.listThreads(view);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
this.listed = undefined;
|
|
266
|
+
if (root !== this.root)
|
|
267
|
+
this.clearPage();
|
|
268
|
+
this.root = root;
|
|
269
|
+
this.ensureStyle();
|
|
270
|
+
const key = JSON.stringify(changes);
|
|
271
|
+
if (key !== this.marked) {
|
|
272
|
+
this.marked = key;
|
|
273
|
+
this.at = -1;
|
|
274
|
+
this.atEnd = false;
|
|
275
|
+
this.marks = markChanges(root, changes);
|
|
276
|
+
setShow(root, this.show);
|
|
277
|
+
this.linkLabels();
|
|
278
|
+
}
|
|
279
|
+
this.stopSources ??= showSources(root);
|
|
280
|
+
this.drawOverlay(view, root);
|
|
281
|
+
this.render();
|
|
282
|
+
this.goToAsked();
|
|
283
|
+
}
|
|
284
|
+
drawOverlay(view, root) {
|
|
285
|
+
const threads = view.threads;
|
|
286
|
+
if (threads.state !== "on") {
|
|
287
|
+
this.disposeOverlay();
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (this.overlay && this.overlayFor === threads.pullRequest.number) {
|
|
291
|
+
this.didChange();
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
this.disposeOverlay();
|
|
295
|
+
this.overlayFor = threads.pullRequest.number;
|
|
296
|
+
this.overlay = createOverlay({
|
|
297
|
+
root,
|
|
298
|
+
host: this.host(),
|
|
299
|
+
columnAt: COLUMN_AT,
|
|
300
|
+
unsentBar: false,
|
|
301
|
+
onUpdate: () => this.updated(),
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* The threads of a page whose anchors didn't arrive. The changes' anchors
|
|
306
|
+
* stand in for the page's, so threads on the files it includes are read too.
|
|
307
|
+
*/
|
|
308
|
+
async listThreads(view) {
|
|
309
|
+
const anchors = (view.page?.changes ?? []).flatMap((change) => change.now
|
|
310
|
+
? [change.now, ...change.now.via.map((at) => ({ source: lineSource(at), via: [] }))]
|
|
311
|
+
: []);
|
|
312
|
+
try {
|
|
313
|
+
this.listed = await this.channel.request("load", {
|
|
314
|
+
path: view.path,
|
|
315
|
+
anchors,
|
|
316
|
+
removed: this.removed(),
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
this.notify(messageOf(error));
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
this.updated();
|
|
324
|
+
}
|
|
325
|
+
/** Goes where the page was opened for: a thread, or its first change. */
|
|
326
|
+
goToAsked() {
|
|
327
|
+
const hash = this.doc.location.hash;
|
|
328
|
+
if (hash.startsWith(THREAD_HASH) && this.overlay) {
|
|
329
|
+
const id = decodeURIComponent(hash.slice(THREAD_HASH.length));
|
|
330
|
+
const { pathname, search } = this.doc.location;
|
|
331
|
+
this.doc.defaultView?.history.replaceState(null, "", `${pathname}${search}`);
|
|
332
|
+
this.overlay.goToThread(id);
|
|
333
|
+
}
|
|
334
|
+
else if (recall(FIRST_KEY) === "1") {
|
|
335
|
+
remember(FIRST_KEY, "0");
|
|
336
|
+
if (this.marks.length > 0)
|
|
337
|
+
this.step(1);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/** Each mark's label opens its block's source. */
|
|
341
|
+
linkLabels() {
|
|
342
|
+
for (const mark of this.marks) {
|
|
343
|
+
const source = mark.element.getAttribute("data-ascribe-source");
|
|
344
|
+
const label = labelOf(mark.element);
|
|
345
|
+
if (source === null || !label)
|
|
346
|
+
continue;
|
|
347
|
+
label.title = `Open ${describeSource(source, mark.element.getAttribute("data-ascribe-via"))}`;
|
|
348
|
+
label.tabIndex = 0;
|
|
349
|
+
label.setAttribute("role", "link");
|
|
350
|
+
const open = (event) => {
|
|
351
|
+
event.preventDefault();
|
|
352
|
+
event.stopPropagation();
|
|
353
|
+
this.openSource(source);
|
|
354
|
+
};
|
|
355
|
+
label.addEventListener("click", open);
|
|
356
|
+
label.addEventListener("keydown", (event) => {
|
|
357
|
+
if (event.key === "Enter")
|
|
358
|
+
open(event);
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
ensureStyle() {
|
|
363
|
+
if (this.style?.isConnected)
|
|
364
|
+
return;
|
|
365
|
+
this.style = this.doc.createElement("style");
|
|
366
|
+
this.style.setAttribute("data-ascribe-review", "");
|
|
367
|
+
this.style.textContent = marksCss;
|
|
368
|
+
this.doc.head.append(this.style);
|
|
369
|
+
}
|
|
370
|
+
clearPage() {
|
|
371
|
+
this.disposeOverlay();
|
|
372
|
+
this.stopSources?.();
|
|
373
|
+
this.stopSources = undefined;
|
|
374
|
+
if (this.root) {
|
|
375
|
+
clearMarks(this.root);
|
|
376
|
+
this.root.classList.remove("ascribe-marks");
|
|
377
|
+
this.root.removeAttribute("data-ascribe-show");
|
|
378
|
+
}
|
|
379
|
+
this.root = undefined;
|
|
380
|
+
this.marked = undefined;
|
|
381
|
+
this.marks = [];
|
|
382
|
+
this.listed = undefined;
|
|
383
|
+
this.at = -1;
|
|
384
|
+
this.atEnd = false;
|
|
385
|
+
this.style?.remove();
|
|
386
|
+
this.style = undefined;
|
|
387
|
+
this.updated();
|
|
388
|
+
}
|
|
389
|
+
disposeOverlay() {
|
|
390
|
+
this.overlay?.dispose();
|
|
391
|
+
this.overlay = undefined;
|
|
392
|
+
this.overlayFor = undefined;
|
|
393
|
+
this.listeners = [];
|
|
394
|
+
}
|
|
395
|
+
didChange() {
|
|
396
|
+
for (const listener of this.listeners)
|
|
397
|
+
listener();
|
|
398
|
+
}
|
|
399
|
+
/** The overlay redrew, or the threads were read: the counts may have changed. */
|
|
400
|
+
updated() {
|
|
401
|
+
const unsent = this.unsent();
|
|
402
|
+
this.app.toggleNotification(unsent > 0 ? { state: true, level: "info" } : { state: false });
|
|
403
|
+
labelButton(this.doc, buttonLabel(unsent));
|
|
404
|
+
this.render();
|
|
405
|
+
}
|
|
406
|
+
unsent() {
|
|
407
|
+
return this.overlay?.unsentCount() ?? this.listed?.pending.count ?? 0;
|
|
408
|
+
}
|
|
409
|
+
// --- The overlay's host ---
|
|
410
|
+
host() {
|
|
411
|
+
const path = () => (this.stage.kind === "on" ? this.stage.view.path : null);
|
|
412
|
+
const ask = (method, params = {}) => this.channel.request(method, { ...params, path: path() });
|
|
413
|
+
return {
|
|
414
|
+
load: () => ask("load", { anchors: this.anchors(), removed: this.removed() }),
|
|
415
|
+
commentTarget: (anchor) => ask("commentTarget", { anchor }),
|
|
416
|
+
comment: (anchor, body, quote) => ask("comment", { anchor, body, quote }),
|
|
417
|
+
reply: (threadId, body, when) => ask("reply", { threadId, body, when }),
|
|
418
|
+
allThreads: () => ask("allThreads"),
|
|
419
|
+
resolve: (threadId, resolved) => ask("resolve", { threadId, resolved }),
|
|
420
|
+
submit: (event, body) => ask("submit", body === undefined ? { event } : { event, body }),
|
|
421
|
+
discard: () => ask("discard"),
|
|
422
|
+
openSource: (anchor) => this.openSource(anchor.source),
|
|
423
|
+
openThread: (threadId, pagePath) => this.openThread(threadId, pagePath),
|
|
424
|
+
notify: (message) => this.notify(message),
|
|
425
|
+
onDidChange: (listener) => {
|
|
426
|
+
this.listeners.push(listener);
|
|
427
|
+
},
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
/** Every anchored block on the page, in its order. */
|
|
431
|
+
anchors() {
|
|
432
|
+
return anchoredBlocks(this.doc)
|
|
433
|
+
.filter((element) => this.root?.contains(element))
|
|
434
|
+
.map(anchorOf);
|
|
435
|
+
}
|
|
436
|
+
/** Where the page's removed, moved, and changed blocks were, for threads on their old text. */
|
|
437
|
+
removed() {
|
|
438
|
+
if (this.stage.kind !== "on")
|
|
439
|
+
return [];
|
|
440
|
+
return (this.stage.view.page?.changes ?? [])
|
|
441
|
+
.filter((c) => c.kind !== "added" && c.was)
|
|
442
|
+
.map((c) => ({ source: c.was?.source ?? "", via: c.was?.via ?? [] }));
|
|
443
|
+
}
|
|
444
|
+
// --- Going places ---
|
|
445
|
+
/** Opens a block's source file at its first line, in the editor Astro opens files in. */
|
|
446
|
+
openSource(source) {
|
|
447
|
+
if (this.stage.kind !== "on")
|
|
448
|
+
return;
|
|
449
|
+
const where = sourceLocation(source);
|
|
450
|
+
if (!where)
|
|
451
|
+
return;
|
|
452
|
+
const { contentRoot: root, separator } = this.stage.view;
|
|
453
|
+
const file = [root, ...where.path.split("/")].join(separator);
|
|
454
|
+
void fetch(`/__open-in-editor?file=${encodeURIComponent(`${file}:${where.line}`)}`).catch(() => this.notify("Couldn't open the editor."));
|
|
455
|
+
}
|
|
456
|
+
openThread(threadId, pagePath) {
|
|
457
|
+
const page = this.routeOf(pagePath);
|
|
458
|
+
if (page === undefined) {
|
|
459
|
+
this.notify("That page isn't one this site shows.");
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
this.doc.location.assign(`${page}${THREAD_HASH}${encodeURIComponent(threadId)}`);
|
|
463
|
+
}
|
|
464
|
+
routeOf(pagePath) {
|
|
465
|
+
if (this.stage.kind !== "on")
|
|
466
|
+
return undefined;
|
|
467
|
+
return this.stage.view.changedPages.find((p) => p.path === pagePath && p.status !== "removed")
|
|
468
|
+
?.route;
|
|
469
|
+
}
|
|
470
|
+
step(by) {
|
|
471
|
+
const total = this.marks.length;
|
|
472
|
+
if (by === 1 && this.at + 1 >= total) {
|
|
473
|
+
this.at = total;
|
|
474
|
+
this.atEnd = true;
|
|
475
|
+
this.render();
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
this.atEnd = false;
|
|
479
|
+
this.at = Math.max(0, Math.min(total - 1, this.at + by));
|
|
480
|
+
if (this.show !== "changes")
|
|
481
|
+
this.setShow("changes");
|
|
482
|
+
const mark = this.marks[this.at];
|
|
483
|
+
if (mark)
|
|
484
|
+
goTo(mark.element);
|
|
485
|
+
this.render();
|
|
486
|
+
}
|
|
487
|
+
setShow(show) {
|
|
488
|
+
this.show = show;
|
|
489
|
+
if (this.root)
|
|
490
|
+
setShow(this.root, show);
|
|
491
|
+
this.overlay?.layout();
|
|
492
|
+
}
|
|
493
|
+
// --- Telling the reviewer ---
|
|
494
|
+
notify(message) {
|
|
495
|
+
this.message = message;
|
|
496
|
+
if (this.messageTimer)
|
|
497
|
+
clearTimeout(this.messageTimer);
|
|
498
|
+
this.messageTimer = setTimeout(() => {
|
|
499
|
+
this.message = undefined;
|
|
500
|
+
this.render();
|
|
501
|
+
}, 6000);
|
|
502
|
+
this.announce(message);
|
|
503
|
+
this.render();
|
|
504
|
+
}
|
|
505
|
+
announce(message) {
|
|
506
|
+
this.live.textContent = "";
|
|
507
|
+
this.live.textContent = message;
|
|
508
|
+
}
|
|
509
|
+
// --- The panel ---
|
|
510
|
+
render() {
|
|
511
|
+
this.applyScheme();
|
|
512
|
+
this.panel.hidden = !this.open;
|
|
513
|
+
this.pad();
|
|
514
|
+
if (!this.open)
|
|
515
|
+
return;
|
|
516
|
+
const focus = this.focused();
|
|
517
|
+
const title = this.el("div", "ttl");
|
|
518
|
+
title.append(this.el("span", "grow", "Ascribe review"));
|
|
519
|
+
if (this.stage.kind === "on") {
|
|
520
|
+
title.append(this.button("Stop Review", "", () => void this.stop()));
|
|
521
|
+
}
|
|
522
|
+
title.append(this.button("Close", "", () => this.app.toggleState({ state: false })));
|
|
523
|
+
const parts = [title, ...this.content()];
|
|
524
|
+
if (this.message !== undefined)
|
|
525
|
+
parts.push(this.notice(this.message));
|
|
526
|
+
this.panel.replaceChildren(...parts);
|
|
527
|
+
this.refocus(focus);
|
|
528
|
+
this.placePanel();
|
|
529
|
+
this.pad();
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Review's colors follow the page's, not the reader's system: a light-only
|
|
533
|
+
* site stays light. The marks and the overlay read `data-ascribe-scheme`
|
|
534
|
+
* on the page's root; a site that sets it itself keeps its own.
|
|
535
|
+
*/
|
|
536
|
+
applyScheme() {
|
|
537
|
+
const site = this.siteScheme;
|
|
538
|
+
const prefersDark = this.doc.defaultView?.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false;
|
|
539
|
+
const scheme = site === "light" || site === "dark"
|
|
540
|
+
? site
|
|
541
|
+
: pageScheme(this.root ?? this.doc.body, prefersDark);
|
|
542
|
+
if (this.panel.dataset["scheme"] !== scheme)
|
|
543
|
+
this.panel.dataset["scheme"] = scheme;
|
|
544
|
+
if (site !== null)
|
|
545
|
+
return;
|
|
546
|
+
const html = this.doc.documentElement;
|
|
547
|
+
if (!this.root)
|
|
548
|
+
html.removeAttribute("data-ascribe-scheme");
|
|
549
|
+
else if (html.getAttribute("data-ascribe-scheme") !== scheme)
|
|
550
|
+
html.setAttribute("data-ascribe-scheme", scheme);
|
|
551
|
+
}
|
|
552
|
+
/** Keeps the panel clear of the column of threads, over the page beside it. */
|
|
553
|
+
placePanel() {
|
|
554
|
+
const style = this.panel.style;
|
|
555
|
+
const start = this.overlay?.columnStart();
|
|
556
|
+
const viewport = this.doc.documentElement.clientWidth;
|
|
557
|
+
const width = Math.min(PANEL_WIDTH, viewport - 2 * PANEL_MARGIN);
|
|
558
|
+
if (start === undefined || (viewport + width) / 2 <= start - PANEL_MARGIN) {
|
|
559
|
+
style.removeProperty("left");
|
|
560
|
+
style.removeProperty("width");
|
|
561
|
+
style.removeProperty("transform");
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
const room = Math.max(0, start - 2 * PANEL_MARGIN);
|
|
565
|
+
const fits = Math.min(PANEL_WIDTH, room);
|
|
566
|
+
style.left = `${PANEL_MARGIN + (room - fits) / 2}px`;
|
|
567
|
+
style.width = `${fits}px`;
|
|
568
|
+
style.transform = "none";
|
|
569
|
+
}
|
|
570
|
+
content() {
|
|
571
|
+
const stage = this.stage;
|
|
572
|
+
switch (stage.kind) {
|
|
573
|
+
case "off": {
|
|
574
|
+
const row = this.el("div", "row");
|
|
575
|
+
row.append(this.el("span", "grow", "Review is off for this project."), this.button("Start Review", "primary", () => void this.start()));
|
|
576
|
+
return [row];
|
|
577
|
+
}
|
|
578
|
+
case "starting":
|
|
579
|
+
return [
|
|
580
|
+
this.row("Starting review: comparing the pages with their base and looking for the pull request…"),
|
|
581
|
+
];
|
|
582
|
+
case "failed": {
|
|
583
|
+
const notice = this.notice(stage.message);
|
|
584
|
+
notice.append(this.button("Try again", "primary", () => void this.start()));
|
|
585
|
+
return [notice];
|
|
586
|
+
}
|
|
587
|
+
case "on":
|
|
588
|
+
return stage.view.kind === "not-page" ? this.notPage(stage.view) : this.onPage(stage.view);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
/** The site's own route: nothing to review, and the pages that changed. */
|
|
592
|
+
notPage(view) {
|
|
593
|
+
const body = this.el("div", "body");
|
|
594
|
+
const { pullRequest } = againstText(view);
|
|
595
|
+
const pages = view.changedPages.filter((p) => p.status !== "removed");
|
|
596
|
+
body.append(this.el("span", "", pages.length === 0
|
|
597
|
+
? "Nothing to review here: this page doesn't come from the Ascribe project, and no page changed."
|
|
598
|
+
: `Nothing to review here: this page doesn't come from the Ascribe project. Pages ${pullRequest ?? "this branch"} changes:`));
|
|
599
|
+
if (pages.length > 0) {
|
|
600
|
+
const list = this.el("ul", "");
|
|
601
|
+
for (const page of pages) {
|
|
602
|
+
const item = this.el("li", "");
|
|
603
|
+
const link = this.el("a", "", page.route);
|
|
604
|
+
link.href = page.route;
|
|
605
|
+
item.append(link, ` ${pageDetail(page)}`);
|
|
606
|
+
list.append(item);
|
|
607
|
+
}
|
|
608
|
+
body.append(list);
|
|
609
|
+
}
|
|
610
|
+
const parts = [body];
|
|
611
|
+
if (view.problem !== null)
|
|
612
|
+
parts.push(this.notice(`Couldn't compare the pages: ${view.problem}`));
|
|
613
|
+
const errors = errorsNotice(view.errors);
|
|
614
|
+
if (errors !== undefined)
|
|
615
|
+
parts.push(this.notice(errors));
|
|
616
|
+
return parts;
|
|
617
|
+
}
|
|
618
|
+
onPage(view) {
|
|
619
|
+
const parts = [this.controls(view)];
|
|
620
|
+
if (this.breakdown && view.page) {
|
|
621
|
+
parts.push(this.el("div", "legend", countsText(view.page.counts) || "No changes"));
|
|
622
|
+
}
|
|
623
|
+
if (view.problem !== null)
|
|
624
|
+
parts.push(this.notice(`Couldn't compare the pages: ${view.problem}`));
|
|
625
|
+
const errors = errorsNotice(view.errors);
|
|
626
|
+
if (errors !== undefined)
|
|
627
|
+
parts.push(this.notice(errors));
|
|
628
|
+
const threads = threadsNotice(view.threads);
|
|
629
|
+
if (threads !== undefined)
|
|
630
|
+
parts.push(this.notice(threads));
|
|
631
|
+
if (this.atEnd)
|
|
632
|
+
parts.push(this.endNotice(view));
|
|
633
|
+
if (!this.arrived)
|
|
634
|
+
parts.push(this.notArrived(view));
|
|
635
|
+
const unsent = this.unsent();
|
|
636
|
+
if (unsent > 0) {
|
|
637
|
+
const bar = this.el("div", "unsent");
|
|
638
|
+
bar.append(this.el("b", "", unsentText(unsent)), this.el("span", "hint", `Only you can see ${unsent === 1 ? "it" : "them"} until you submit.`), this.el("span", "spacer"));
|
|
639
|
+
if (this.overlay) {
|
|
640
|
+
bar.append(this.button("Submit review…", "primary", () => this.overlay?.submitReview()));
|
|
641
|
+
}
|
|
642
|
+
parts.push(bar);
|
|
643
|
+
}
|
|
644
|
+
return parts;
|
|
645
|
+
}
|
|
646
|
+
/** The review controls, in one row. */
|
|
647
|
+
controls(view) {
|
|
648
|
+
const row = this.el("div", "row");
|
|
649
|
+
const info = this.el("span", "grow");
|
|
650
|
+
const { pullRequest, base } = againstText(view);
|
|
651
|
+
if (pullRequest !== null && view.threads.state === "on") {
|
|
652
|
+
const link = this.el("a", "", pullRequest);
|
|
653
|
+
link.href = view.threads.pullRequest.url;
|
|
654
|
+
link.target = "_blank";
|
|
655
|
+
link.rel = "noopener noreferrer";
|
|
656
|
+
link.title = `Open pull request ${pullRequest} on GitHub`;
|
|
657
|
+
info.append(link, " against ");
|
|
658
|
+
}
|
|
659
|
+
else {
|
|
660
|
+
info.append("Against ");
|
|
661
|
+
}
|
|
662
|
+
const baseName = this.el("b", "", base ?? "the base");
|
|
663
|
+
if (view.base) {
|
|
664
|
+
baseName.title = `Compared with ${(view.base.merge_base ?? view.base.commit).slice(0, 7)}, where this branch left ${view.base.requested}`;
|
|
665
|
+
}
|
|
666
|
+
info.append(baseName);
|
|
667
|
+
const page = view.page;
|
|
668
|
+
if (page?.status === "added")
|
|
669
|
+
info.append(" · a new page");
|
|
670
|
+
if (page && !page.own_file_changed && page.status === "changed" && page.because.length > 0) {
|
|
671
|
+
info.append(" · changed only through ");
|
|
672
|
+
page.because.forEach((cause, i) => {
|
|
673
|
+
if (i > 0)
|
|
674
|
+
info.append(", ");
|
|
675
|
+
info.append(this.el("code", "", cause));
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
row.append(info);
|
|
679
|
+
if (this.arrived) {
|
|
680
|
+
const total = this.marks.length;
|
|
681
|
+
const stepping = this.at >= 0 && this.at < total && this.show === "changes";
|
|
682
|
+
const count = this.button(position(total, stepping ? this.at : -1), "link position", () => {
|
|
683
|
+
this.breakdown = !this.breakdown;
|
|
684
|
+
this.render();
|
|
685
|
+
});
|
|
686
|
+
count.setAttribute("aria-expanded", String(this.breakdown));
|
|
687
|
+
if (page)
|
|
688
|
+
count.title = countsText(page.counts);
|
|
689
|
+
const shows = this.el("div", "segmented");
|
|
690
|
+
shows.setAttribute("role", "group");
|
|
691
|
+
shows.setAttribute("aria-label", "Show");
|
|
692
|
+
for (const [show, label] of SHOWS) {
|
|
693
|
+
const choice = this.button(label, "", () => {
|
|
694
|
+
this.setShow(show);
|
|
695
|
+
this.render();
|
|
696
|
+
});
|
|
697
|
+
choice.setAttribute("aria-pressed", String(this.show === show));
|
|
698
|
+
shows.append(choice);
|
|
699
|
+
}
|
|
700
|
+
const previous = this.button("↑", "ghost square", () => this.step(-1));
|
|
701
|
+
previous.setAttribute("aria-label", "Previous change");
|
|
702
|
+
previous.title = "Previous change";
|
|
703
|
+
previous.disabled = total === 0;
|
|
704
|
+
const next = this.button("↓", "ghost square", () => this.step(1));
|
|
705
|
+
next.setAttribute("aria-label", "Next change");
|
|
706
|
+
next.title = "Next change";
|
|
707
|
+
row.append(count, shows, previous, next);
|
|
708
|
+
}
|
|
709
|
+
if (this.overlay) {
|
|
710
|
+
const n = this.overlay.threadCount();
|
|
711
|
+
const all = this.button(n === undefined ? "Comments" : `Comments (${n})`, "ghost", () => this.overlay?.showAllComments());
|
|
712
|
+
all.setAttribute("aria-label", "All comments on this pull request");
|
|
713
|
+
all.setAttribute("aria-haspopup", "dialog");
|
|
714
|
+
row.append(all);
|
|
715
|
+
}
|
|
716
|
+
const refresh = this.button("Refresh", "ghost", () => void this.refresh());
|
|
717
|
+
refresh.setAttribute("aria-label", "Refresh comments and changes");
|
|
718
|
+
row.append(refresh);
|
|
719
|
+
return row;
|
|
720
|
+
}
|
|
721
|
+
/** Past the last change: the next changed page. */
|
|
722
|
+
endNotice(view) {
|
|
723
|
+
const notice = this.notice(this.marks.length === 0
|
|
724
|
+
? "This page has no changes."
|
|
725
|
+
: "That was the last change on this page.");
|
|
726
|
+
const next = nextChangedPage(view.changedPages, view.path);
|
|
727
|
+
if (next === null)
|
|
728
|
+
notice.append(this.el("span", "", "No other page changed."));
|
|
729
|
+
else {
|
|
730
|
+
const target = next.page;
|
|
731
|
+
notice.append(this.button(nextPageText(next), "primary", () => {
|
|
732
|
+
remember(FIRST_KEY, "1");
|
|
733
|
+
this.doc.location.assign(target.route);
|
|
734
|
+
}));
|
|
735
|
+
}
|
|
736
|
+
return notice;
|
|
737
|
+
}
|
|
738
|
+
/** The page's changes and threads, listed, since nothing could be placed on it. */
|
|
739
|
+
notArrived(view) {
|
|
740
|
+
const body = this.el("div", "body");
|
|
741
|
+
const changes = view.page?.changes ?? [];
|
|
742
|
+
const threads = listedThreads(this.listed);
|
|
743
|
+
const what = [
|
|
744
|
+
view.threads.state === "on" ? plural(threads.length, "comment") : undefined,
|
|
745
|
+
plural(changes.length, "change"),
|
|
746
|
+
]
|
|
747
|
+
.filter((x) => x !== undefined)
|
|
748
|
+
.join(" and ");
|
|
749
|
+
body.append(this.el("b", "", anchoredBlocks(this.doc).length === 0
|
|
750
|
+
? `This page has ${what}, but its blocks carry no source anchors.`
|
|
751
|
+
: `This page has ${what}, but most of its changed blocks carry no source anchors.`), this.el("span", "", "A layout or component is probably dropping the data-ascribe-source attributes, so nothing can be placed on the page. Everything is listed here instead."));
|
|
752
|
+
if (changes.length > 0) {
|
|
753
|
+
body.append(this.el("span", "hint", "Changes"));
|
|
754
|
+
const list = this.el("ul", "");
|
|
755
|
+
for (const change of changes) {
|
|
756
|
+
const item = this.el("li", "", `${change.kind} `);
|
|
757
|
+
const source = change.now?.source;
|
|
758
|
+
if (source !== undefined) {
|
|
759
|
+
item.append(this.button(describeSource(source), "link", () => this.openSource(source)));
|
|
760
|
+
}
|
|
761
|
+
else {
|
|
762
|
+
item.append(this.el("span", "", change.text ?? "text"));
|
|
763
|
+
}
|
|
764
|
+
list.append(item);
|
|
765
|
+
}
|
|
766
|
+
body.append(list);
|
|
767
|
+
}
|
|
768
|
+
if (threads.length > 0) {
|
|
769
|
+
body.append(this.el("span", "hint", "Comments"));
|
|
770
|
+
for (const thread of threads)
|
|
771
|
+
body.append(this.threadCard(thread));
|
|
772
|
+
}
|
|
773
|
+
return body;
|
|
774
|
+
}
|
|
775
|
+
/** A thread, read only: open it on GitHub to reply. */
|
|
776
|
+
threadCard(thread) {
|
|
777
|
+
const card = this.el("div", "thread");
|
|
778
|
+
const where = this.el("div", "where");
|
|
779
|
+
const lines = thread.lines;
|
|
780
|
+
const { label, removed } = threadLocation(thread);
|
|
781
|
+
// A thread on removed text is on the base's lines, which the working tree doesn't have.
|
|
782
|
+
if (lines !== undefined && !removed) {
|
|
783
|
+
const source = `${thread.path.split("/").map(encodeURIComponent).join("/")}:${lines.first}-${lines.last}`;
|
|
784
|
+
const link = this.button(label, "link", () => this.openSource(source));
|
|
785
|
+
link.title = `Open ${thread.path}, line ${lines.first}`;
|
|
786
|
+
where.append(link);
|
|
787
|
+
}
|
|
788
|
+
else {
|
|
789
|
+
const name = this.el("span", "", label);
|
|
790
|
+
name.title = thread.path;
|
|
791
|
+
where.append(name);
|
|
792
|
+
}
|
|
793
|
+
if (removed)
|
|
794
|
+
where.append(" · On removed text");
|
|
795
|
+
if (thread.resolved)
|
|
796
|
+
where.append(" · Resolved");
|
|
797
|
+
if (thread.outdated)
|
|
798
|
+
where.append(" · Outdated");
|
|
799
|
+
card.append(where);
|
|
800
|
+
for (const comment of thread.comments) {
|
|
801
|
+
const body = this.el("div", "comment");
|
|
802
|
+
body.append(this.el("b", "", comment.author?.login ?? "ghost"), renderMarkdown(this.doc, comment.body));
|
|
803
|
+
card.append(body);
|
|
804
|
+
}
|
|
805
|
+
const url = thread.comments[0]?.url;
|
|
806
|
+
if (url !== undefined && url !== "") {
|
|
807
|
+
const link = this.el("a", "", "Open on GitHub");
|
|
808
|
+
link.href = url;
|
|
809
|
+
link.target = "_blank";
|
|
810
|
+
link.rel = "noopener noreferrer";
|
|
811
|
+
card.append(link);
|
|
812
|
+
}
|
|
813
|
+
return card;
|
|
814
|
+
}
|
|
815
|
+
/** Room below the page, so the panel never hides what's focused. */
|
|
816
|
+
pad() {
|
|
817
|
+
const html = this.doc.documentElement;
|
|
818
|
+
const body = this.doc.body;
|
|
819
|
+
if (this.open && this.stage.kind === "on") {
|
|
820
|
+
this.padding ??= { scroll: html.style.scrollPaddingBottom, body: body.style.paddingBottom };
|
|
821
|
+
const room = `${this.panel.offsetHeight + 90}px`;
|
|
822
|
+
html.style.scrollPaddingBottom = room;
|
|
823
|
+
body.style.paddingBottom = room;
|
|
824
|
+
}
|
|
825
|
+
else if (this.padding) {
|
|
826
|
+
html.style.scrollPaddingBottom = this.padding.scroll;
|
|
827
|
+
body.style.paddingBottom = this.padding.body;
|
|
828
|
+
this.padding = undefined;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
// --- DOM ---
|
|
832
|
+
row(text) {
|
|
833
|
+
const row = this.el("div", "row");
|
|
834
|
+
row.append(this.el("span", "grow", text));
|
|
835
|
+
return row;
|
|
836
|
+
}
|
|
837
|
+
notice(text) {
|
|
838
|
+
const notice = this.el("div", "notice");
|
|
839
|
+
const words = this.el("span", "");
|
|
840
|
+
// A command or a setting is written in backticks: show it as code.
|
|
841
|
+
text.split("`").forEach((part, i) => {
|
|
842
|
+
if (part !== "")
|
|
843
|
+
words.append(i % 2 === 1 ? this.el("code", "", part) : part);
|
|
844
|
+
});
|
|
845
|
+
notice.append(words, this.el("span", "spacer"));
|
|
846
|
+
return notice;
|
|
847
|
+
}
|
|
848
|
+
el(tag, className, text) {
|
|
849
|
+
const el = this.doc.createElement(tag);
|
|
850
|
+
if (className)
|
|
851
|
+
el.className = className;
|
|
852
|
+
if (text !== undefined)
|
|
853
|
+
el.textContent = text;
|
|
854
|
+
return el;
|
|
855
|
+
}
|
|
856
|
+
button(text, className, onClick) {
|
|
857
|
+
const button = this.doc.createElement("button");
|
|
858
|
+
button.type = "button";
|
|
859
|
+
if (className)
|
|
860
|
+
button.className = className;
|
|
861
|
+
button.textContent = text;
|
|
862
|
+
button.addEventListener("click", onClick);
|
|
863
|
+
return button;
|
|
864
|
+
}
|
|
865
|
+
/** The focused control's label, to focus it again after a redraw. */
|
|
866
|
+
focused() {
|
|
867
|
+
const active = this.panel.getRootNode().activeElement;
|
|
868
|
+
return active instanceof HTMLElement && this.panel.contains(active)
|
|
869
|
+
? (active.getAttribute("aria-label") ?? active.textContent ?? undefined)
|
|
870
|
+
: undefined;
|
|
871
|
+
}
|
|
872
|
+
refocus(label) {
|
|
873
|
+
if (label === undefined)
|
|
874
|
+
return;
|
|
875
|
+
for (const button of Array.from(this.panel.querySelectorAll("button, a"))) {
|
|
876
|
+
if ((button.getAttribute("aria-label") ?? button.textContent) === label) {
|
|
877
|
+
button.focus();
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
/** The label the marks put on a block: inside it at its start, or just before it. */
|
|
884
|
+
function labelOf(element) {
|
|
885
|
+
const inside = element.querySelector(":scope > .ascribe-label");
|
|
886
|
+
if (inside)
|
|
887
|
+
return inside;
|
|
888
|
+
const before = element.previousElementSibling;
|
|
889
|
+
return before instanceof HTMLElement && before.classList.contains("ascribe-label-before")
|
|
890
|
+
? before
|
|
891
|
+
: undefined;
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* Puts the unsent count in the toolbar button's tooltip. The toolbar has no
|
|
895
|
+
* API for a button's text, so this reads its markup and does nothing if
|
|
896
|
+
* that changed.
|
|
897
|
+
*/
|
|
898
|
+
function labelButton(doc, text) {
|
|
899
|
+
const toolbar = doc.querySelector("astro-dev-toolbar")?.shadowRoot;
|
|
900
|
+
const button = toolbar?.querySelector(`button.item[data-app-id="${APP_ID}"]`);
|
|
901
|
+
const tip = button?.querySelector(".item-tooltip");
|
|
902
|
+
if (tip)
|
|
903
|
+
tip.textContent = text;
|
|
904
|
+
button?.setAttribute("aria-label", text);
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* Every thread read for a page whose anchors didn't arrive, once each: on
|
|
908
|
+
* the changes' blocks, on removed text, or on no block.
|
|
909
|
+
*/
|
|
910
|
+
function listedThreads(data) {
|
|
911
|
+
if (!data)
|
|
912
|
+
return [];
|
|
913
|
+
const { blocks, removed, detached } = data.threads;
|
|
914
|
+
const seen = new Set();
|
|
915
|
+
return [...blocks, ...removed]
|
|
916
|
+
.flatMap((entry) => entry.threads)
|
|
917
|
+
.concat(detached)
|
|
918
|
+
.filter((thread) => !seen.has(thread.id) && seen.add(thread.id));
|
|
919
|
+
}
|
|
920
|
+
/** An include's place (`<path>:<line>`) as a block's source (`<path>:<line>-<line>`). */
|
|
921
|
+
function lineSource(at) {
|
|
922
|
+
const colon = at.lastIndexOf(":");
|
|
923
|
+
const line = at.slice(colon + 1);
|
|
924
|
+
return colon > 0 && /^\d+$/.test(line) ? `${at}-${line}` : at;
|
|
925
|
+
}
|
|
926
|
+
function plural(count, noun) {
|
|
927
|
+
return count === 1 ? `1 ${noun}` : `${count} ${noun}s`;
|
|
928
|
+
}
|
|
929
|
+
function messageOf(error) {
|
|
930
|
+
if (typeof error === "object" && error !== null && "message" in error) {
|
|
931
|
+
return String(error.message);
|
|
932
|
+
}
|
|
933
|
+
return String(error);
|
|
934
|
+
}
|
|
935
|
+
function remember(key, value) {
|
|
936
|
+
try {
|
|
937
|
+
sessionStorage.setItem(key, value);
|
|
938
|
+
}
|
|
939
|
+
catch {
|
|
940
|
+
// Storage is off: the panel just starts closed on the next page.
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
function recall(key) {
|
|
944
|
+
try {
|
|
945
|
+
return sessionStorage.getItem(key);
|
|
946
|
+
}
|
|
947
|
+
catch {
|
|
948
|
+
return null;
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
//# sourceMappingURL=app.js.map
|