@ascribed/review 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.
Files changed (70) hide show
  1. package/LICENSE +373 -0
  2. package/README.md +183 -0
  3. package/dist/github/index.d.ts +7 -0
  4. package/dist/github/index.js +8 -0
  5. package/dist/github/index.js.map +1 -0
  6. package/dist/github/marker.d.ts +63 -0
  7. package/dist/github/marker.js +156 -0
  8. package/dist/github/marker.js.map +1 -0
  9. package/dist/github/queries.d.ts +16 -0
  10. package/dist/github/queries.js +174 -0
  11. package/dist/github/queries.js.map +1 -0
  12. package/dist/github/read.d.ts +84 -0
  13. package/dist/github/read.js +141 -0
  14. package/dist/github/read.js.map +1 -0
  15. package/dist/github/repository.d.ts +30 -0
  16. package/dist/github/repository.js +77 -0
  17. package/dist/github/repository.js.map +1 -0
  18. package/dist/github/requests.d.ts +65 -0
  19. package/dist/github/requests.js +274 -0
  20. package/dist/github/requests.js.map +1 -0
  21. package/dist/github/session.d.ts +143 -0
  22. package/dist/github/session.js +581 -0
  23. package/dist/github/session.js.map +1 -0
  24. package/dist/github/transport.d.ts +33 -0
  25. package/dist/github/transport.js +206 -0
  26. package/dist/github/transport.js.map +1 -0
  27. package/dist/marks/index.d.ts +92 -0
  28. package/dist/marks/index.js +766 -0
  29. package/dist/marks/index.js.map +1 -0
  30. package/dist/overlay/index.d.ts +4 -0
  31. package/dist/overlay/index.js +7 -0
  32. package/dist/overlay/index.js.map +1 -0
  33. package/dist/overlay/markdown.d.ts +4 -0
  34. package/dist/overlay/markdown.js +368 -0
  35. package/dist/overlay/markdown.js.map +1 -0
  36. package/dist/overlay/overlay.d.ts +47 -0
  37. package/dist/overlay/overlay.js +1710 -0
  38. package/dist/overlay/overlay.js.map +1 -0
  39. package/dist/overlay/style.d.ts +1 -0
  40. package/dist/overlay/style.js +570 -0
  41. package/dist/overlay/style.js.map +1 -0
  42. package/dist/overlay/text.d.ts +33 -0
  43. package/dist/overlay/text.js +200 -0
  44. package/dist/overlay/text.js.map +1 -0
  45. package/dist/overlay/types.d.ts +60 -0
  46. package/dist/overlay/types.js +2 -0
  47. package/dist/overlay/types.js.map +1 -0
  48. package/dist/place/anchor.d.ts +23 -0
  49. package/dist/place/anchor.js +49 -0
  50. package/dist/place/anchor.js.map +1 -0
  51. package/dist/place/index.d.ts +4 -0
  52. package/dist/place/index.js +6 -0
  53. package/dist/place/index.js.map +1 -0
  54. package/dist/place/lines.d.ts +56 -0
  55. package/dist/place/lines.js +280 -0
  56. package/dist/place/lines.js.map +1 -0
  57. package/dist/place/place.d.ts +77 -0
  58. package/dist/place/place.js +182 -0
  59. package/dist/place/place.js.map +1 -0
  60. package/dist/shared/errors.d.ts +40 -0
  61. package/dist/shared/errors.js +22 -0
  62. package/dist/shared/errors.js.map +1 -0
  63. package/dist/shared/git.d.ts +4 -0
  64. package/dist/shared/git.js +29 -0
  65. package/dist/shared/git.js.map +1 -0
  66. package/dist/shared/types.d.ts +56 -0
  67. package/dist/shared/types.js +3 -0
  68. package/dist/shared/types.js.map +1 -0
  69. package/package.json +61 -0
  70. package/src/marks/marks.css +361 -0
@@ -0,0 +1,1710 @@
1
+ // The review overlay: a pull request's threads beside the blocks of a rendered
2
+ // page. It reads the page's source anchors to find each thread's block, gets
3
+ // everything else from its host (`OverlayHost`), and draws in shadow roots of
4
+ // its own, so the page's styles and the overlay's stay apart:
5
+ //
6
+ // - above the page, the threads with no block on it (detached);
7
+ // - over the page, in a layer: at wide widths, a column of threads beside the
8
+ // page, each aligned with its block, with a line between them on hover and
9
+ // focus; at narrow widths, the panel a block's count marker opens; and the
10
+ // dialogs (submit, all comments);
11
+ // - below the page, a bar while there are unsent comments;
12
+ // - in the page, on the block under the pointer or focus, a Comment button,
13
+ // at narrow widths a count marker on each block with threads, and on a
14
+ // tab's label and a `<details>`' summary how many comments they hold. These are
15
+ // elements with `data-ascribe-ui`, as the marks' are, each holding a shadow
16
+ // root, so they sit in the page's reading and tab order.
17
+ //
18
+ // Comment bodies are rendered by `markdown.ts`, as DOM nodes.
19
+ import { findBlock, goTo, reveal, tabButton } from "../marks/index.js";
20
+ import { anchorKey, formatSource } from "../place/anchor.js";
21
+ import { renderMarkdown } from "./markdown.js";
22
+ import { OVERLAY_CSS } from "./style.js";
23
+ import { authorName, blockText, hasUnsent, initials, linesLabel, listLabel, onRemovedText, plural, relativeTime, whereLabel, } from "./text.js";
24
+ const COLUMN_WIDTH = 250;
25
+ const COLUMN_GAP = 12;
26
+ const MARKER_ROOM = "44px";
27
+ const EVENTS = [
28
+ ["COMMENT", "Comment"],
29
+ ["APPROVE", "Approve"],
30
+ ["REQUEST_CHANGES", "Request changes"],
31
+ ];
32
+ /** Puts the review overlay on a rendered page. */
33
+ export function createOverlay(options) {
34
+ return new ReviewOverlay(options);
35
+ }
36
+ class ReviewOverlay {
37
+ root;
38
+ host;
39
+ doc;
40
+ columnAt;
41
+ onUpdate;
42
+ unsentBar;
43
+ beforeHost;
44
+ afterHost;
45
+ layerHost;
46
+ toolsHost;
47
+ before;
48
+ after;
49
+ layer;
50
+ tools;
51
+ live;
52
+ data;
53
+ all;
54
+ entries = [];
55
+ wide = true;
56
+ loadSeq = 0;
57
+ composer;
58
+ panel;
59
+ /** The block the panel is for, which may have no threads yet. */
60
+ panelAnchor;
61
+ dialog;
62
+ discardAsk = false;
63
+ submitting = false;
64
+ dialogError;
65
+ filter = "open";
66
+ event = "COMMENT";
67
+ expanded = new Set();
68
+ original = new Set();
69
+ drafts = new Map();
70
+ busy = new Set();
71
+ errors = new Map();
72
+ linked;
73
+ /** Which entries were hidden in the page when last drawn. */
74
+ drawnHidden = "";
75
+ linkTimer;
76
+ pendingGoTo;
77
+ /** The read whose threads are drawn. */
78
+ loaded = 0;
79
+ /** The block the Comment button is on. */
80
+ toolsOn;
81
+ /** What had focus before a dialog or the panel opened, to give it back. */
82
+ opener;
83
+ /** The page's blocks this overlay made focusable, and the blocks it made room on. */
84
+ focusable = [];
85
+ padded = [];
86
+ markers = [];
87
+ /** The comment counts on tabs' labels and `<details>`' summaries. */
88
+ hints = [];
89
+ containerPosition;
90
+ /** The padding below the page for the column to end in, in pixels. */
91
+ room = 0;
92
+ cleanups = [];
93
+ disposed = false;
94
+ constructor(options) {
95
+ this.root = options.root;
96
+ this.host = options.host;
97
+ this.doc = this.root.ownerDocument;
98
+ this.columnAt = options.columnAt ?? 600;
99
+ this.onUpdate = options.onUpdate;
100
+ this.unsentBar = options.unsentBar ?? true;
101
+ this.beforeHost = this.uiHost("div");
102
+ this.afterHost = this.uiHost("div");
103
+ this.layerHost = this.uiHost("div");
104
+ this.toolsHost = this.uiHost("span");
105
+ this.before = this.shadow(this.beforeHost);
106
+ this.after = this.shadow(this.afterHost);
107
+ this.layer = this.shadow(this.layerHost);
108
+ this.tools = this.shadow(this.toolsHost);
109
+ const container = this.container();
110
+ if (this.doc.defaultView?.getComputedStyle(container).position === "static") {
111
+ this.containerPosition = container.style.position;
112
+ container.style.position = "relative";
113
+ }
114
+ this.root.before(this.beforeHost);
115
+ this.root.after(this.afterHost);
116
+ this.afterHost.after(this.layerHost);
117
+ Object.assign(this.afterHost.style, { position: "sticky", bottom: "0", zIndex: "3" });
118
+ Object.assign(this.layerHost.style, {
119
+ position: "absolute",
120
+ top: "0",
121
+ left: "0",
122
+ width: "100%",
123
+ height: "100%",
124
+ pointerEvents: "none",
125
+ });
126
+ Object.assign(this.toolsHost.style, { position: "absolute", zIndex: "2" });
127
+ this.toolsHost.hidden = true;
128
+ this.live = this.el("div", "sr-only");
129
+ this.live.setAttribute("role", "status");
130
+ this.live.setAttribute("aria-live", "polite");
131
+ this.layer.append(this.live);
132
+ this.listen();
133
+ this.host.onDidChange(() => {
134
+ if (!this.disposed)
135
+ void this.refresh();
136
+ });
137
+ void this.refresh();
138
+ }
139
+ // --- Public ---
140
+ async refresh() {
141
+ const seq = ++this.loadSeq;
142
+ let data;
143
+ let all;
144
+ try {
145
+ [data, all] = await Promise.all([
146
+ this.host.load(),
147
+ this.host.allThreads().catch(() => undefined),
148
+ ]);
149
+ }
150
+ catch (error) {
151
+ if (seq !== this.loadSeq || this.disposed)
152
+ return;
153
+ this.announce(messageOf(error));
154
+ return;
155
+ }
156
+ if (seq !== this.loadSeq || this.disposed)
157
+ return;
158
+ this.data = data;
159
+ this.loaded = seq;
160
+ if (all !== undefined)
161
+ this.all = all;
162
+ this.render();
163
+ const goal = this.pendingGoTo;
164
+ if (goal !== undefined) {
165
+ this.pendingGoTo = undefined;
166
+ this.goToThread(goal);
167
+ }
168
+ }
169
+ layout() {
170
+ if (this.disposed || this.data === undefined)
171
+ return;
172
+ // Crossing the column's width, or a tab or `<details>` showing or hiding
173
+ // a block with threads, changes what's drawn, not only where.
174
+ if (this.measureWide() !== this.wide || this.hiddenState() !== this.drawnHidden) {
175
+ this.render();
176
+ return;
177
+ }
178
+ this.place();
179
+ }
180
+ goToThread(threadId) {
181
+ // Until the threads being read arrive: the page may have just changed.
182
+ if (this.data === undefined || this.loaded !== this.loadSeq) {
183
+ this.pendingGoTo = threadId;
184
+ return true;
185
+ }
186
+ const entry = this.entries.find((e) => e.threads.some((t) => t.id === threadId));
187
+ const detached = this.data.threads.detached.some((t) => t.id === threadId);
188
+ if (entry === undefined && !detached)
189
+ return false;
190
+ this.dialog = undefined;
191
+ this.expanded.add(threadId);
192
+ if (entry?.element) {
193
+ reveal(entry.element);
194
+ if (!this.wide) {
195
+ this.panel = entry.key;
196
+ this.panelAnchor = entry.removed ? undefined : entry.anchor;
197
+ }
198
+ this.render();
199
+ goTo(entry.element);
200
+ if (this.wide)
201
+ this.link(entry.key, 1800);
202
+ }
203
+ else {
204
+ this.render();
205
+ }
206
+ const card = this.findCard(threadId);
207
+ if (card) {
208
+ if (typeof card.scrollIntoView === "function" && !entry?.element) {
209
+ card.scrollIntoView({ block: "center" });
210
+ }
211
+ card.focus({ preventScroll: true });
212
+ }
213
+ return true;
214
+ }
215
+ showAllComments() {
216
+ this.openDialog("all");
217
+ }
218
+ threadCount() {
219
+ return this.all?.length;
220
+ }
221
+ unsentCount() {
222
+ return this.data?.pending.count;
223
+ }
224
+ columnStart() {
225
+ if (this.disposed || !this.wide || this.data === undefined)
226
+ return undefined;
227
+ return this.root.getBoundingClientRect().right + COLUMN_GAP;
228
+ }
229
+ submitReview() {
230
+ if ((this.data?.pending.count ?? 0) > 0)
231
+ this.openDialog("submit");
232
+ }
233
+ dispose() {
234
+ if (this.disposed)
235
+ return;
236
+ this.disposed = true;
237
+ for (const cleanup of this.cleanups)
238
+ cleanup();
239
+ this.undecorate();
240
+ this.beforeHost.remove();
241
+ this.afterHost.remove();
242
+ this.layerHost.remove();
243
+ this.toolsHost.remove();
244
+ if (this.containerPosition !== undefined) {
245
+ this.container().style.position = this.containerPosition;
246
+ }
247
+ this.root.style.removeProperty("margin-inline-end");
248
+ this.makeRoom(0);
249
+ if (this.linkTimer)
250
+ clearTimeout(this.linkTimer);
251
+ }
252
+ // --- Setup ---
253
+ container() {
254
+ return this.root.parentElement ?? this.doc.body;
255
+ }
256
+ uiHost(tag) {
257
+ const host = this.doc.createElement(tag);
258
+ host.setAttribute("data-ascribe-ui", "");
259
+ host.setAttribute("data-ascribe-overlay", "");
260
+ return host;
261
+ }
262
+ shadow(host) {
263
+ const shadow = host.attachShadow({ mode: "open" });
264
+ adoptStyles(shadow, this.doc);
265
+ return shadow;
266
+ }
267
+ listen() {
268
+ const on = (target, type, handler, options) => {
269
+ target.addEventListener(type, handler, options);
270
+ this.cleanups.push(() => target.removeEventListener(type, handler, options));
271
+ };
272
+ on(this.root, "mouseover", (event) => this.pointAt(event.target));
273
+ on(this.root, "focusin", (event) => this.pointAt(event.target));
274
+ on(this.root, "mouseout", (event) => {
275
+ const to = event.relatedTarget;
276
+ if (to instanceof Node && this.toolsOn?.contains(to))
277
+ return;
278
+ if (to instanceof Node && (this.toolsHost.contains(to) || to === this.toolsHost))
279
+ return;
280
+ if (!this.toolsOn?.contains(this.doc.activeElement))
281
+ this.hideTools();
282
+ this.link(undefined);
283
+ });
284
+ on(this.root, "focusout", (event) => {
285
+ const to = event.relatedTarget;
286
+ if (to instanceof Node && this.toolsOn?.contains(to))
287
+ return;
288
+ this.hideTools();
289
+ this.link(undefined);
290
+ });
291
+ on(this.root, "click", (event) => this.clickBlock(event));
292
+ on(this.root, "keydown", (event) => this.keyOnBlock(event));
293
+ // A tab or a `<details>` in the page changed what shows: place again.
294
+ on(this.root, "toggle", () => this.layout(), { capture: true });
295
+ on(this.root, "click", () => requestFrame(this.doc, () => this.layout()));
296
+ for (const shadow of [this.before, this.after, this.layer, this.tools]) {
297
+ on(shadow, "click", (event) => this.clickLink(event));
298
+ on(shadow, "keydown", (event) => this.keyInOverlay(event));
299
+ on(shadow, "dblclick", (event) => event.stopPropagation());
300
+ }
301
+ const view = this.doc.defaultView;
302
+ if (view)
303
+ on(view, "resize", () => this.layout());
304
+ if (view && "ResizeObserver" in view) {
305
+ // Placed in the next frame: placing can pad the page, which resizes
306
+ // what's observed, and doing that inside the observer's callback is a
307
+ // "ResizeObserver loop" error on the page (a dev server reports it).
308
+ let queued = false;
309
+ const observer = new view.ResizeObserver(() => {
310
+ if (queued)
311
+ return;
312
+ queued = true;
313
+ requestFrame(this.doc, () => {
314
+ queued = false;
315
+ this.layout();
316
+ });
317
+ });
318
+ observer.observe(this.root);
319
+ observer.observe(this.container());
320
+ this.cleanups.push(() => observer.disconnect());
321
+ }
322
+ }
323
+ // --- Reading the page ---
324
+ measureWide() {
325
+ return this.container().clientWidth >= this.columnAt;
326
+ }
327
+ anchorOf(element) {
328
+ const source = element.getAttribute("data-ascribe-source");
329
+ if (source === null)
330
+ return undefined;
331
+ const via = (element.getAttribute("data-ascribe-via") ?? "").split(" ").filter(Boolean);
332
+ return { source, via };
333
+ }
334
+ /** The block an event's target is in: the innermost anchored element. */
335
+ blockOf(target) {
336
+ if (!(target instanceof Element))
337
+ return undefined;
338
+ if (target.closest("[data-ascribe-overlay]") && target !== this.toolsHost) {
339
+ // A marker: the block it's on.
340
+ const marker = this.markers.find((m) => m.host === target.closest("[data-ascribe-overlay]"));
341
+ if (marker)
342
+ return marker.block;
343
+ }
344
+ const block = target.closest("[data-ascribe-source]");
345
+ return block && this.root.contains(block) ? block : undefined;
346
+ }
347
+ /** The block's text as the page shows it, for a comment to quote. */
348
+ shownText(anchor) {
349
+ const key = anchorKey(anchor);
350
+ for (const el of Array.from(this.root.querySelectorAll("[data-ascribe-source]"))) {
351
+ const at = this.anchorOf(el);
352
+ if (at && anchorKey(at) === key)
353
+ return blockText(el) || undefined;
354
+ }
355
+ return undefined;
356
+ }
357
+ removedElement(anchor) {
358
+ for (const el of Array.from(this.root.querySelectorAll("[data-ascribe-was-source]"))) {
359
+ if (el.getAttribute("data-ascribe-was-source") === anchor.source)
360
+ return el;
361
+ }
362
+ return null;
363
+ }
364
+ readEntries() {
365
+ const data = this.data;
366
+ if (!data)
367
+ return [];
368
+ const entries = [];
369
+ for (const { anchor, threads } of data.threads.blocks) {
370
+ entries.push({
371
+ key: anchorKey(anchor),
372
+ anchor,
373
+ threads,
374
+ removed: false,
375
+ element: findBlock(this.root, anchor),
376
+ });
377
+ }
378
+ for (const { anchor, threads } of data.threads.removed) {
379
+ entries.push({
380
+ key: `removed ${anchorKey(anchor)}`,
381
+ anchor,
382
+ threads,
383
+ removed: true,
384
+ element: this.removedElement(anchor),
385
+ });
386
+ }
387
+ // A block being commented on, or whose panel is open, may have no threads yet.
388
+ for (const anchor of [this.composer?.anchor, this.panelAnchor]) {
389
+ if (anchor === undefined)
390
+ continue;
391
+ const key = anchorKey(anchor);
392
+ if (entries.some((e) => e.key === key))
393
+ continue;
394
+ entries.push({
395
+ key,
396
+ anchor,
397
+ threads: [],
398
+ removed: false,
399
+ element: findBlock(this.root, anchor),
400
+ });
401
+ }
402
+ // Page order.
403
+ entries.sort((a, b) => {
404
+ if (!a.element || !b.element || a.element === b.element)
405
+ return 0;
406
+ return a.element.compareDocumentPosition(b.element) & Node.DOCUMENT_POSITION_FOLLOWING
407
+ ? -1
408
+ : 1;
409
+ });
410
+ return entries;
411
+ }
412
+ /** Whether what the page shows hides a block: removed text in "as it will be", new text in "as it was". */
413
+ hiddenByShow(element) {
414
+ const show = this.root.getAttribute("data-ascribe-show");
415
+ const kind = element.closest("[data-ascribe-change]")?.getAttribute("data-ascribe-change");
416
+ if (show === "will")
417
+ return kind === "removed" || kind === "moved-from";
418
+ if (show === "was")
419
+ return kind === "added" || kind === "moved";
420
+ return false;
421
+ }
422
+ /** Where a block is hidden in the page: an unselected tab or a closed `<details>`. */
423
+ hiddenIn(element) {
424
+ for (let node = element.parentElement; node !== null && node !== this.root.parentElement; node = node.parentElement) {
425
+ if (node.localName === "ascribe-tab" && node.hidden) {
426
+ return `In the ${node.getAttribute("label") ?? ""} tab, which isn't showing.`;
427
+ }
428
+ if (node.localName === "details" && !node.open) {
429
+ const summary = node.querySelector(":scope > summary");
430
+ if (summary?.contains(element))
431
+ continue;
432
+ return `Inside “${summary ? blockText(summary) : ""}”, which is closed.`;
433
+ }
434
+ }
435
+ return undefined;
436
+ }
437
+ hiddenState() {
438
+ return this.entries
439
+ .map((e) => (e.element && (this.hiddenIn(e.element) || this.hiddenByShow(e.element)) ? 1 : 0))
440
+ .join("");
441
+ }
442
+ /**
443
+ * The element to align a block's threads with: the block, or, while it's
444
+ * in an unselected tab or a closed `<details>`, the tabs or the details.
445
+ */
446
+ visibleFor(element) {
447
+ let shown = element;
448
+ for (let node = element?.parentElement ?? null; node !== null && node !== this.root; node = node.parentElement) {
449
+ if (node.localName === "ascribe-tab" && node.hidden)
450
+ shown = node.parentElement;
451
+ if (node.localName === "details" && !node.open) {
452
+ const summary = node.querySelector(":scope > summary");
453
+ if (!(shown && summary?.contains(shown)))
454
+ shown = node;
455
+ }
456
+ }
457
+ return shown;
458
+ }
459
+ // --- Drawing ---
460
+ render() {
461
+ if (this.disposed)
462
+ return;
463
+ const focus = this.focusKey();
464
+ this.wide = this.measureWide();
465
+ this.entries = this.readEntries();
466
+ if (this.panel !== undefined && !this.entries.some((e) => e.key === this.panel)) {
467
+ this.panel = undefined;
468
+ this.panelAnchor = undefined;
469
+ }
470
+ this.drawnHidden = this.hiddenState();
471
+ this.decorate();
472
+ this.before.replaceChildren(...this.styleNodes(this.before), ...this.drawDetached());
473
+ this.after.replaceChildren(...this.styleNodes(this.after), ...this.drawUnsentBar());
474
+ const layer = this.el("div", "layer");
475
+ if (this.wide)
476
+ layer.append(...this.drawColumn());
477
+ const sheet = this.wide ? null : this.drawSheet();
478
+ if (sheet)
479
+ layer.append(sheet);
480
+ const dialog = this.drawDialog();
481
+ if (dialog)
482
+ layer.append(dialog);
483
+ this.layer.replaceChildren(...this.styleNodes(this.layer), layer, this.live);
484
+ this.drawTools();
485
+ this.restoreFocus(focus);
486
+ this.place();
487
+ this.onUpdate?.();
488
+ }
489
+ /** A `<style>` element where constructed stylesheets aren't available. */
490
+ styleNodes(shadow) {
491
+ return Array.from(shadow.children).filter((child) => child.localName === "style" && child.hasAttribute("data-overlay"));
492
+ }
493
+ /** Makes the page's blocks focusable, and at narrow widths puts count markers on them. */
494
+ decorate() {
495
+ this.undecorate();
496
+ const threadsOn = this.data !== undefined;
497
+ if (!threadsOn) {
498
+ this.root.style.removeProperty("margin-inline-end");
499
+ return;
500
+ }
501
+ if (this.wide)
502
+ this.root.style.setProperty("margin-inline-end", `${COLUMN_WIDTH + COLUMN_GAP}px`);
503
+ else
504
+ this.root.style.removeProperty("margin-inline-end");
505
+ for (const el of Array.from(this.root.querySelectorAll("[data-ascribe-source]"))) {
506
+ if (el.hasAttribute("tabindex"))
507
+ continue;
508
+ el.tabIndex = 0;
509
+ this.focusable.push(el);
510
+ }
511
+ this.drawHints();
512
+ if (this.wide)
513
+ return;
514
+ for (const entry of this.entries) {
515
+ const count = entry.threads.length;
516
+ if (count === 0 || !entry.element || this.hiddenByShow(entry.element))
517
+ continue;
518
+ const host = this.uiHost("span");
519
+ Object.assign(host.style, { position: "absolute", zIndex: "1" });
520
+ const shadow = this.shadow(host);
521
+ const marker = this.button("marker", String(count), () => this.openEntry(entry));
522
+ marker.setAttribute("aria-label", `${plural(count, "comment")} on this block. Show ${count === 1 ? "it" : "them"}.`);
523
+ marker.setAttribute("aria-haspopup", "dialog");
524
+ shadow.append(marker);
525
+ // A block in a closed `<details>` or an unselected tab has its marker
526
+ // on what shows in its place; its panel says where it is.
527
+ const element = this.visibleFor(entry.element) ?? entry.element;
528
+ if (this.markers.some((m) => m.block === element))
529
+ continue;
530
+ if (isVoid(element))
531
+ element.after(host);
532
+ else
533
+ element.append(host);
534
+ this.markers.push({ host, block: element });
535
+ this.padded.push({ element, before: element.style.paddingInlineEnd });
536
+ element.style.paddingInlineEnd = MARKER_ROOM;
537
+ }
538
+ }
539
+ /**
540
+ * On each tab's label and each `<details>`' summary, how many comments are
541
+ * on the blocks they can hide, which an unselected tab or a closed
542
+ * `<details>` would otherwise keep out of sight.
543
+ */
544
+ drawHints() {
545
+ const counts = new Map();
546
+ for (const entry of this.entries) {
547
+ const element = entry.element;
548
+ const count = entry.threads.length;
549
+ if (count === 0 || !element || this.hiddenByShow(element))
550
+ continue;
551
+ for (let node = element.parentElement; node !== null && node !== this.root; node = node.parentElement) {
552
+ let on = null;
553
+ if (node.localName === "ascribe-tab")
554
+ on = tabButton(node);
555
+ if (node.localName === "details") {
556
+ const summary = node.querySelector(":scope > summary");
557
+ if (summary && !summary.contains(element))
558
+ on = summary;
559
+ }
560
+ if (on)
561
+ counts.set(on, (counts.get(on) ?? 0) + count);
562
+ }
563
+ }
564
+ // Each count starts with a space, as the marks' hints do, so the label
565
+ // reads "Linux 1 change 1 comment".
566
+ for (const [on, count] of counts) {
567
+ const host = this.uiHost("span");
568
+ this.shadow(host).append(this.el("span", "hint-count", ` ${plural(count, "comment")}`));
569
+ on.append(host);
570
+ this.hints.push(host);
571
+ }
572
+ }
573
+ undecorate() {
574
+ for (const host of this.hints)
575
+ host.remove();
576
+ this.hints = [];
577
+ for (const el of this.focusable)
578
+ el.removeAttribute("tabindex");
579
+ this.focusable = [];
580
+ for (const { element, before } of this.padded)
581
+ element.style.paddingInlineEnd = before;
582
+ this.padded = [];
583
+ for (const { host } of this.markers)
584
+ host.remove();
585
+ this.markers = [];
586
+ }
587
+ drawDetached() {
588
+ const detached = this.data?.threads.detached ?? [];
589
+ if (detached.length === 0)
590
+ return [];
591
+ const box = this.el("section", "detached");
592
+ box.setAttribute("aria-label", "Comments with no block on this page");
593
+ box.append(this.el("div", "", detached.length === 1
594
+ ? "1 comment has no block on this page any more:"
595
+ : `${detached.length} comments have no block on this page any more:`), ...detached.map((thread) => this.card(thread, "detached", undefined)));
596
+ return [box];
597
+ }
598
+ drawUnsentBar() {
599
+ const count = this.data?.pending.count ?? 0;
600
+ if (count === 0 || !this.unsentBar)
601
+ return [];
602
+ const bar = this.el("div", "unsent-bar");
603
+ bar.setAttribute("role", "region");
604
+ bar.setAttribute("aria-label", "Unsent comments");
605
+ bar.append(this.el("b", "", plural(count, "unsent comment")), this.el("span", "hint", `Only you can see ${count === 1 ? "it" : "them"} until you submit.`), this.el("span", "spacer"), this.button("primary", "Submit review…", () => this.openDialog("submit"), "submit-open"));
606
+ return [bar];
607
+ }
608
+ drawColumn() {
609
+ const column = this.el("div", "column");
610
+ for (const entry of this.entries) {
611
+ if (entry.element && this.hiddenByShow(entry.element))
612
+ continue;
613
+ const cards = this.cardsFor(entry, "column");
614
+ if (cards.length === 0)
615
+ continue;
616
+ const slot = this.el("div", "slot");
617
+ slot.dataset["key"] = entry.key;
618
+ slot.append(...cards);
619
+ slot.addEventListener("mouseenter", () => this.link(entry.key));
620
+ slot.addEventListener("mouseleave", () => this.link(undefined));
621
+ slot.addEventListener("focusin", () => this.link(entry.key));
622
+ slot.addEventListener("focusout", (event) => {
623
+ if (!(event.relatedTarget instanceof Node && slot.contains(event.relatedTarget))) {
624
+ this.link(undefined);
625
+ }
626
+ });
627
+ column.append(slot);
628
+ }
629
+ const highlight = this.el("div", "highlight");
630
+ highlight.hidden = true;
631
+ const svg = this.doc.createElementNS("http://www.w3.org/2000/svg", "svg");
632
+ svg.setAttribute("class", "connector");
633
+ svg.setAttribute("aria-hidden", "true");
634
+ return [highlight, svg, column];
635
+ }
636
+ cardsFor(entry, view) {
637
+ const cards = entry.threads.map((thread) => this.card(thread, view, entry));
638
+ if (this.composer?.key === entry.key)
639
+ cards.push(this.drawComposer(this.composer));
640
+ return cards;
641
+ }
642
+ drawSheet() {
643
+ const entry = this.entries.find((e) => e.key === this.panel);
644
+ if (!entry)
645
+ return null;
646
+ const sheet = this.el("div", "sheet");
647
+ sheet.setAttribute("role", "dialog");
648
+ sheet.setAttribute("aria-label", "Comments on this block");
649
+ const head = this.el("div", "head");
650
+ head.append(this.el("b", "", entry.removed ? "Removed text" : sourceLabel(entry.anchor)), this.el("span", "spacer"));
651
+ if (!entry.removed) {
652
+ head.append(this.button("ghost", "Source", () => this.host.openSource(entry.anchor)));
653
+ if (this.composer?.key !== entry.key) {
654
+ head.append(this.button("ghost", "Comment", () => this.startComment(entry.anchor)));
655
+ }
656
+ }
657
+ head.append(this.button("ghost", "Close", () => this.closePanel(), "panel-close"));
658
+ sheet.append(head);
659
+ const cards = this.cardsFor(entry, "panel");
660
+ if (cards.length === 0)
661
+ sheet.append(this.el("p", "hint", "No comments on this block yet."));
662
+ sheet.append(...cards);
663
+ return sheet;
664
+ }
665
+ card(thread, view, entry) {
666
+ const data = this.data;
667
+ const viewer = data?.viewer ?? "";
668
+ const collapsed = thread.resolved && !this.expanded.has(thread.id);
669
+ const card = this.el("article", `thread${collapsed ? " collapsed" : ""}`);
670
+ card.dataset["thread"] = thread.id;
671
+ card.dataset["focus"] = `thread:${thread.id}`;
672
+ card.tabIndex = -1;
673
+ const first = thread.comments[0];
674
+ card.setAttribute("aria-label", `Comment by ${first ? authorName(first.author, viewer) : "someone"} on ${whereLabel(thread)}`);
675
+ const head = this.el("div", "th");
676
+ const where = whereLabel(thread);
677
+ // In the narrow panel, the sheet's title already names the block.
678
+ if (!(view === "panel" && entry && !entry.removed && where === sourceLabel(entry.anchor))) {
679
+ head.append(this.el("span", "", where));
680
+ }
681
+ if (onRemovedText(thread))
682
+ head.append(this.badge("removed", "On removed text"));
683
+ if (thread.resolved)
684
+ head.append(this.badge("resolved", "Resolved"));
685
+ if (thread.detached !== undefined)
686
+ head.append(this.badge("detached", "Detached"));
687
+ if (thread.outdated && thread.detached === undefined) {
688
+ head.append(this.badge("outdated", "Outdated"));
689
+ }
690
+ if (thread.kind === "conversation")
691
+ head.append(this.badge("summary", "In the review summary"));
692
+ if (hasUnsent(thread))
693
+ head.append(this.badge("unsent", "Unsent"));
694
+ if (head.childElementCount > 0)
695
+ card.append(head);
696
+ // Actions sit on their own row, so the header never pushes one onto a line by itself.
697
+ const acts = this.el("div", "acts");
698
+ if (thread.resolved) {
699
+ acts.append(this.button("link", collapsed ? "Expand" : "Collapse", () => {
700
+ if (collapsed)
701
+ this.expanded.add(thread.id);
702
+ else
703
+ this.expanded.delete(thread.id);
704
+ this.render();
705
+ }, `expand:${thread.id}`));
706
+ }
707
+ if (thread.outdated && thread.detached === undefined && thread.quote !== undefined) {
708
+ const open = this.original.has(thread.id);
709
+ acts.append(this.button("link", open ? "Hide original text" : "Original text", () => {
710
+ if (open)
711
+ this.original.delete(thread.id);
712
+ else
713
+ this.original.add(thread.id);
714
+ this.render();
715
+ }, `original:${thread.id}`));
716
+ }
717
+ if (entry && !entry.removed) {
718
+ acts.append(this.button("link", "Open source", () => this.host.openSource(threadSource(thread, entry))));
719
+ }
720
+ // A detached thread has no block to go to: GitHub shows it in full.
721
+ const url = thread.comments.at(-1)?.url ?? thread.comments[0]?.url;
722
+ if (thread.detached !== undefined && url !== undefined && /^https?:\/\//i.test(url)) {
723
+ const github = this.el("a", "link", "View on GitHub");
724
+ github.setAttribute("href", url);
725
+ github.setAttribute("target", "_blank");
726
+ github.setAttribute("rel", "noopener noreferrer");
727
+ acts.append(github);
728
+ }
729
+ if (thread.kind === "review") {
730
+ const can = thread.resolved ? thread.canUnresolve : thread.canResolve;
731
+ if (can) {
732
+ const resolve = this.button("link", thread.resolved ? "Reopen" : "Resolve", () => void this.resolve(thread), `resolve:${thread.id}`);
733
+ resolve.title = "Sent to GitHub right away, not held with your review";
734
+ resolve.disabled = this.busy.has(thread.id);
735
+ acts.append(resolve);
736
+ }
737
+ }
738
+ if (acts.childElementCount > 0)
739
+ card.append(acts);
740
+ const hidden = view !== "detached" && entry?.element ? this.hiddenIn(entry.element) : undefined;
741
+ if (hidden && entry?.element) {
742
+ const element = entry.element;
743
+ const inside = this.el("div", "inside", `${hidden} `);
744
+ inside.append(this.button("link", "Show it", () => {
745
+ reveal(element);
746
+ this.render();
747
+ goTo(element);
748
+ this.link(entry.key, 1800);
749
+ }));
750
+ card.append(inside);
751
+ }
752
+ if (collapsed)
753
+ return card;
754
+ if (thread.quote !== undefined &&
755
+ thread.quote.trim() !== "" &&
756
+ (thread.detached !== undefined || this.original.has(thread.id))) {
757
+ const orig = this.el("div", "orig");
758
+ orig.append(this.el("span", "", thread.detached !== undefined
759
+ ? "The comment was on this text, which is no longer on the page: "
760
+ : "The text when the comment was made: "), this.el("q", "", thread.quote));
761
+ card.append(orig);
762
+ }
763
+ const now = Date.now();
764
+ for (const comment of thread.comments) {
765
+ const row = this.el("div", "comment");
766
+ const avatar = this.el("span", "avatar", initials(comment.author));
767
+ avatar.setAttribute("aria-hidden", "true");
768
+ const text = this.el("div", "");
769
+ text.append(this.el("span", "who", authorName(comment.author, viewer)), this.el("span", "when", comment.pending ? "not sent yet" : relativeTime(comment.createdAt, now)));
770
+ const body = this.el("div", "body");
771
+ body.append(renderMarkdown(this.doc, comment.body));
772
+ text.append(body);
773
+ row.append(avatar, text);
774
+ card.append(row);
775
+ }
776
+ if (thread.kind === "review" && thread.canReply) {
777
+ card.append(this.drawReply(thread));
778
+ }
779
+ return card;
780
+ }
781
+ drawReply(thread) {
782
+ const key = `reply:${thread.id}`;
783
+ const box = this.el("div", "reply");
784
+ const input = this.doc.createElement("textarea");
785
+ input.rows = 1;
786
+ input.placeholder = "Reply…";
787
+ input.setAttribute("aria-label", "Reply to thread");
788
+ input.dataset["focus"] = key;
789
+ input.value = this.drafts.get(key) ?? "";
790
+ const busy = this.busy.has(thread.id);
791
+ input.disabled = busy;
792
+ input.addEventListener("keydown", (event) => {
793
+ if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
794
+ event.preventDefault();
795
+ void this.reply(thread, "withReview");
796
+ }
797
+ });
798
+ const held = (this.data?.pending.count ?? 0) > 0;
799
+ const now = this.button("ghost", "Reply now", () => {
800
+ if (held) {
801
+ this.announce(HELD_REASON);
802
+ return;
803
+ }
804
+ void this.reply(thread, "now");
805
+ }, `reply-now:${thread.id}`);
806
+ // GitHub adds every reply to the review while it has unsent comments, so
807
+ // a reply can't go at once until it's submitted or discarded. The button
808
+ // stays focusable, so the reason can be read.
809
+ if (held) {
810
+ now.setAttribute("aria-disabled", "true");
811
+ now.title = HELD_REASON;
812
+ const reason = this.el("span", "sr-only", HELD_REASON);
813
+ reason.id = `held-${cssId(thread.id)}`;
814
+ now.setAttribute("aria-describedby", reason.id);
815
+ box.append(reason);
816
+ }
817
+ else {
818
+ now.title = "Send this reply to GitHub right away";
819
+ }
820
+ const add = this.button("ghost", "Add to review", () => void this.reply(thread, "withReview"), `reply-add:${thread.id}`);
821
+ add.title = "Hold this reply until you submit the review";
822
+ // Nothing to send until there's a reply. "Reply now" stays focusable
823
+ // while it's held, so its reason can be read.
824
+ const enable = () => {
825
+ const empty = input.value.trim() === "";
826
+ now.disabled = busy || (empty && !held);
827
+ add.disabled = busy || empty;
828
+ };
829
+ enable();
830
+ input.addEventListener("input", () => {
831
+ this.drafts.set(key, input.value);
832
+ enable();
833
+ });
834
+ box.prepend(input);
835
+ box.append(now, add);
836
+ const error = this.errors.get(key);
837
+ if (error) {
838
+ const line = this.el("div", "error", error);
839
+ line.setAttribute("role", "alert");
840
+ box.append(line);
841
+ }
842
+ return box;
843
+ }
844
+ drawComposer(composer) {
845
+ const key = `comment:${composer.key}`;
846
+ const box = this.el("div", "composer");
847
+ box.setAttribute("role", "group");
848
+ box.setAttribute("aria-label", `Comment on ${sourceLabel(composer.anchor)}`);
849
+ const target = composer.target;
850
+ const refused = target?.kind === "push-first";
851
+ if (target === undefined)
852
+ box.append(this.el("div", "hint", "Checking where this comment can go…"));
853
+ if (target?.kind === "summary") {
854
+ box.append(this.el("div", "hint", target.reason === "file"
855
+ ? "This page's file isn't in the pull request, so GitHub can't put a comment on its lines. Your comment will go in your review's summary and still show here."
856
+ : "GitHub only takes comments on the lines a pull request changes and the few around them. Your comment will go in your review's summary and still show here."));
857
+ }
858
+ if (target?.kind === "push-first") {
859
+ const refuse = this.el("div", "refuse", target.message);
860
+ refuse.setAttribute("role", "alert");
861
+ box.append(refuse);
862
+ }
863
+ const text = this.doc.createElement("textarea");
864
+ text.setAttribute("aria-label", "Comment");
865
+ text.placeholder = `Comment on ${sourceLabel(composer.anchor)}`;
866
+ text.dataset["focus"] = key;
867
+ text.value = this.drafts.get(key) ?? "";
868
+ text.disabled = refused || composer.busy;
869
+ text.addEventListener("keydown", (event) => {
870
+ if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
871
+ event.preventDefault();
872
+ void this.addComment();
873
+ }
874
+ });
875
+ box.append(text);
876
+ if (composer.error) {
877
+ const error = this.el("div", "error", composer.error);
878
+ error.setAttribute("role", "alert");
879
+ box.append(error);
880
+ }
881
+ const row = this.el("div", "row");
882
+ const add = this.button("primary", "Add to review", () => void this.addComment(), "composer-add");
883
+ const enable = () => {
884
+ add.disabled = refused || composer.busy || target === undefined || text.value.trim() === "";
885
+ };
886
+ enable();
887
+ text.addEventListener("input", () => {
888
+ this.drafts.set(key, text.value);
889
+ enable();
890
+ });
891
+ row.append(add, this.button("ghost", "Cancel", () => this.cancelComment(), "composer-cancel"));
892
+ if (!refused)
893
+ row.append(this.el("span", "hint", "Nothing is sent until you submit the review."));
894
+ box.append(row);
895
+ return box;
896
+ }
897
+ drawDialog() {
898
+ const data = this.data;
899
+ if (!data || this.dialog === undefined)
900
+ return null;
901
+ const backdrop = this.el("div", "backdrop");
902
+ backdrop.addEventListener("click", (event) => {
903
+ if (event.target === backdrop)
904
+ this.closeDialog();
905
+ });
906
+ const dialog = this.el("div", "dialog");
907
+ dialog.setAttribute("role", "dialog");
908
+ dialog.setAttribute("aria-modal", "true");
909
+ backdrop.append(dialog);
910
+ if (this.dialog === "all")
911
+ this.drawAll(dialog, data);
912
+ else
913
+ this.drawSubmit(dialog, data);
914
+ return backdrop;
915
+ }
916
+ drawAll(dialog, data) {
917
+ dialog.setAttribute("aria-label", "All comments");
918
+ const head = this.el("div", "row");
919
+ head.append(this.el("h2", "", `All comments on #${data.pullRequest.number}`), this.el("span", "spacer"), this.button("ghost", "Close", () => this.closeDialog(), "dialog-close"));
920
+ dialog.append(head);
921
+ const all = this.all;
922
+ if (all === undefined) {
923
+ dialog.append(this.el("p", "hint", "Reading the comments…"));
924
+ return;
925
+ }
926
+ const groups = {
927
+ open: all.filter((s) => !s.thread.resolved && s.thread.detached === undefined),
928
+ resolved: all.filter((s) => s.thread.resolved),
929
+ detached: all.filter((s) => s.thread.detached !== undefined),
930
+ unsent: all.filter((s) => hasUnsent(s.thread)),
931
+ };
932
+ const filters = this.el("div", "segmented");
933
+ filters.setAttribute("role", "group");
934
+ filters.setAttribute("aria-label", "Filter");
935
+ for (const [filter, label] of [
936
+ ["open", "Open"],
937
+ ["resolved", "Resolved"],
938
+ ["detached", "Detached"],
939
+ ["unsent", "Unsent"],
940
+ ]) {
941
+ const choice = this.button("", `${label} ${groups[filter].length}`, () => {
942
+ this.filter = filter;
943
+ this.render();
944
+ }, `filter:${filter}`);
945
+ choice.setAttribute("aria-pressed", String(this.filter === filter));
946
+ filters.append(choice);
947
+ }
948
+ dialog.append(filters);
949
+ const list = this.el("ul", "thread-list");
950
+ for (const summary of groups[this.filter]) {
951
+ const { thread, pages } = summary;
952
+ const first = thread.comments[0];
953
+ const page = pages.length > 1 ? `on ${pages.length} pages` : (pages[0]?.title ?? pages[0]?.path ?? "");
954
+ // As on its card: a thread with no block is detached, not outdated.
955
+ const outdated = thread.outdated && thread.detached === undefined;
956
+ const where = [listLabel(thread), outdated ? "outdated" : "", page]
957
+ .filter(Boolean)
958
+ .join(" · ");
959
+ const more = thread.comments.length > 1 ? ` (+${thread.comments.length - 1})` : "";
960
+ const item = this.el("li", "");
961
+ const jump = this.button("", "", () => this.jump(summary), `jump:${thread.id}`);
962
+ jump.append(this.el("span", "where", where), this.el("span", "first", first ? `${authorName(first.author, data.viewer)}: ${oneLine(first.body)}${more}` : ""));
963
+ item.append(jump);
964
+ list.append(item);
965
+ }
966
+ if (groups[this.filter].length === 0)
967
+ list.append(this.el("li", "hint", "None."));
968
+ dialog.append(list);
969
+ }
970
+ drawSubmit(dialog, data) {
971
+ dialog.setAttribute("aria-label", "Submit review");
972
+ const count = data.pending.count;
973
+ dialog.append(this.el("h2", "", `Submit review to #${data.pullRequest.number}`));
974
+ if (count > 0) {
975
+ dialog.append(this.el("span", "hint", count === 1 ? "This one will be sent:" : `These ${count} will be sent:`));
976
+ const list = this.el("ul", "unsent-list");
977
+ for (const summary of this.all ?? []) {
978
+ const { thread } = summary;
979
+ thread.comments.forEach((comment, index) => {
980
+ if (!comment.pending)
981
+ return;
982
+ const item = this.el("li", "");
983
+ item.append(this.button("link", listLabel(thread), () => this.jump(summary)));
984
+ const how = index > 0
985
+ ? " (reply): "
986
+ : thread.kind === "conversation"
987
+ ? " (in the summary): "
988
+ : ": ";
989
+ item.append(this.el("span", "", `${how}${oneLine(comment.body)}`));
990
+ list.append(item);
991
+ });
992
+ }
993
+ dialog.append(list);
994
+ }
995
+ const choices = this.doc.createElement("fieldset");
996
+ const legend = this.el("legend", "hint", "Submit as");
997
+ choices.append(legend);
998
+ for (const [event, label] of EVENTS) {
999
+ const option = this.doc.createElement("label");
1000
+ const radio = this.doc.createElement("input");
1001
+ radio.type = "radio";
1002
+ radio.name = "event";
1003
+ radio.value = event;
1004
+ radio.checked = this.event === event;
1005
+ radio.dataset["focus"] = `event:${event}`;
1006
+ radio.addEventListener("change", () => {
1007
+ if (radio.checked)
1008
+ this.event = event;
1009
+ });
1010
+ option.append(radio, ` ${label}`);
1011
+ choices.append(option);
1012
+ }
1013
+ dialog.append(choices);
1014
+ const summary = this.doc.createElement("textarea");
1015
+ summary.placeholder = "Summary (optional)";
1016
+ summary.setAttribute("aria-label", "Summary (optional)");
1017
+ summary.dataset["focus"] = "summary";
1018
+ summary.value = this.drafts.get("summary") ?? "";
1019
+ summary.addEventListener("input", () => this.drafts.set("summary", summary.value));
1020
+ dialog.append(summary);
1021
+ if (this.dialogError) {
1022
+ const error = this.el("div", "error", this.dialogError);
1023
+ error.setAttribute("role", "alert");
1024
+ dialog.append(error);
1025
+ }
1026
+ if (this.discardAsk) {
1027
+ const confirm = this.el("div", "confirm");
1028
+ confirm.setAttribute("role", "alert");
1029
+ const row = this.el("div", "row");
1030
+ row.append(this.button("danger", "Discard comments", () => void this.discard(), "discard-yes"), this.button("ghost", "Keep review", () => {
1031
+ this.discardAsk = false;
1032
+ this.render();
1033
+ }, "discard-no"));
1034
+ confirm.append(this.el("b", "", `Discard ${plural(count, "unsent comment")}?`), this.el("span", "", " They will be deleted and can't be brought back."), row);
1035
+ dialog.append(confirm);
1036
+ return;
1037
+ }
1038
+ const row = this.el("div", "row");
1039
+ const submit = this.button("primary", "Submit review", () => void this.submit(), "submit");
1040
+ submit.disabled = this.submitting;
1041
+ row.append(submit, this.button("ghost", "Cancel", () => this.closeDialog(), "dialog-close"), this.el("span", "spacer"));
1042
+ if (count > 0) {
1043
+ row.append(this.button("link", "Discard…", () => {
1044
+ this.discardAsk = true;
1045
+ this.render();
1046
+ this.focusOn("discard-no");
1047
+ }, "discard"));
1048
+ }
1049
+ dialog.append(row);
1050
+ }
1051
+ /** The Comment button, on the block under the pointer or focus. */
1052
+ drawTools() {
1053
+ const button = this.button("tool", "Comment", () => {
1054
+ const block = this.toolsOn;
1055
+ const anchor = block && this.anchorOf(block);
1056
+ if (anchor)
1057
+ this.startComment(anchor);
1058
+ }, "tools");
1059
+ button.setAttribute("aria-label", "Comment on this block");
1060
+ this.tools.replaceChildren(...this.styleNodes(this.tools), button);
1061
+ if (!this.wide || this.data === undefined)
1062
+ this.hideTools();
1063
+ }
1064
+ // --- Placing ---
1065
+ place() {
1066
+ if (this.disposed)
1067
+ return;
1068
+ const base = this.layerHost.getBoundingClientRect();
1069
+ const column = this.layer.querySelector(".column");
1070
+ if (column) {
1071
+ const page = this.root.getBoundingClientRect();
1072
+ column.style.left = `${page.right - base.left + COLUMN_GAP}px`;
1073
+ column.style.width = `${COLUMN_WIDTH}px`;
1074
+ let previous = 0;
1075
+ for (const slot of Array.from(column.querySelectorAll(":scope > .slot"))) {
1076
+ const entry = this.entries.find((e) => e.key === slot.dataset["key"]);
1077
+ const element = this.visibleFor(entry?.element ?? null);
1078
+ const at = element ? element.getBoundingClientRect().top - base.top : previous;
1079
+ const top = Math.max(at, previous);
1080
+ slot.style.top = `${top}px`;
1081
+ previous = top + slot.offsetHeight + 2;
1082
+ }
1083
+ column.style.height = `${previous}px`;
1084
+ // The column can run past the page: make room below it. The room
1085
+ // already made isn't the page's, or making it would undo it.
1086
+ const overrun = Math.ceil(previous - (page.bottom - this.room - base.top));
1087
+ this.makeRoom(overrun > 0 ? overrun : 0);
1088
+ }
1089
+ else {
1090
+ this.makeRoom(0);
1091
+ }
1092
+ for (const { host, block } of this.markers)
1093
+ this.pin(host, block);
1094
+ if (this.toolsOn && !this.toolsHost.hidden)
1095
+ this.pin(this.toolsHost, this.toolsOn);
1096
+ if (this.linked !== undefined)
1097
+ this.drawLink(this.linked);
1098
+ }
1099
+ /** Pads the page's bottom by `room` pixels, for the column to end in. */
1100
+ makeRoom(room) {
1101
+ if (room === this.room)
1102
+ return;
1103
+ this.room = room;
1104
+ if (room > 0)
1105
+ this.root.style.setProperty("padding-bottom", `${room}px`);
1106
+ else
1107
+ this.root.style.removeProperty("padding-bottom");
1108
+ }
1109
+ /** Positions an element of the page at a block's top right corner. */
1110
+ pin(host, block) {
1111
+ const parent = host.offsetParent ?? this.doc.body;
1112
+ const outer = parent.getBoundingClientRect();
1113
+ const box = block.getBoundingClientRect();
1114
+ host.style.top = `${box.top - outer.top - parent.clientTop}px`;
1115
+ host.style.left = `${box.right - outer.left - parent.clientLeft - host.offsetWidth}px`;
1116
+ }
1117
+ pointAt(target) {
1118
+ if (this.data === undefined)
1119
+ return;
1120
+ const block = this.blockOf(target);
1121
+ if (!block)
1122
+ return;
1123
+ if (this.wide) {
1124
+ const key = this.anchorKeyOf(block);
1125
+ if (key !== undefined && this.entries.some((e) => e.key === key))
1126
+ this.link(key);
1127
+ if (block !== this.toolsOn || this.toolsHost.hidden)
1128
+ this.showTools(block);
1129
+ }
1130
+ }
1131
+ anchorKeyOf(block) {
1132
+ const anchor = this.anchorOf(block);
1133
+ return anchor && anchorKey(anchor);
1134
+ }
1135
+ showTools(block) {
1136
+ if (this.toolsHost.parentElement !== block) {
1137
+ if (isVoid(block))
1138
+ block.after(this.toolsHost);
1139
+ else
1140
+ block.append(this.toolsHost);
1141
+ }
1142
+ this.toolsOn = block;
1143
+ this.toolsHost.hidden = false;
1144
+ this.pin(this.toolsHost, block);
1145
+ }
1146
+ hideTools() {
1147
+ this.toolsHost.hidden = true;
1148
+ }
1149
+ /** Highlights a block and its threads and joins them with a line; `undefined` clears it. */
1150
+ link(key, forMs) {
1151
+ if (this.linkTimer)
1152
+ clearTimeout(this.linkTimer);
1153
+ this.linkTimer = undefined;
1154
+ this.linked = key;
1155
+ this.drawLink(key);
1156
+ if (key !== undefined && forMs !== undefined) {
1157
+ this.linkTimer = setTimeout(() => this.link(undefined), forMs);
1158
+ }
1159
+ }
1160
+ drawLink(key) {
1161
+ const svg = this.layer.querySelector("svg.connector");
1162
+ const highlight = this.layer.querySelector(".highlight");
1163
+ for (const slot of Array.from(this.layer.querySelectorAll(".slot.linked"))) {
1164
+ slot.classList.remove("linked");
1165
+ }
1166
+ svg?.replaceChildren();
1167
+ if (highlight)
1168
+ highlight.hidden = true;
1169
+ if (key === undefined || !svg || !highlight)
1170
+ return;
1171
+ const entry = this.entries.find((e) => e.key === key);
1172
+ const element = this.visibleFor(entry?.element ?? null);
1173
+ const slot = Array.from(this.layer.querySelectorAll(".slot")).find((s) => s.dataset["key"] === key);
1174
+ if (!element || !slot)
1175
+ return;
1176
+ slot.classList.add("linked");
1177
+ const base = this.layerHost.getBoundingClientRect();
1178
+ const a = element.getBoundingClientRect();
1179
+ const s = slot.getBoundingClientRect();
1180
+ Object.assign(highlight.style, {
1181
+ top: `${a.top - base.top}px`,
1182
+ left: `${a.left - base.left}px`,
1183
+ width: `${a.width}px`,
1184
+ height: `${a.height}px`,
1185
+ });
1186
+ highlight.hidden = false;
1187
+ const x1 = a.right - base.left;
1188
+ const y1 = a.top - base.top + 12;
1189
+ const x2 = s.left - base.left;
1190
+ const y2 = s.top - base.top + 14;
1191
+ const mid = (x1 + x2) / 2;
1192
+ const path = this.doc.createElementNS("http://www.w3.org/2000/svg", "path");
1193
+ path.setAttribute("d", `M${x1} ${y1} H${mid} V${y2} H${x2}`);
1194
+ svg.append(path);
1195
+ }
1196
+ // --- What the reviewer does ---
1197
+ clickBlock(event) {
1198
+ if (this.data === undefined || this.wide || event.defaultPrevented)
1199
+ return;
1200
+ const target = event.target instanceof Element ? event.target : null;
1201
+ if (!target || target.closest("a, button, input, textarea, select, summary, [role=tab]"))
1202
+ return;
1203
+ if (this.doc.getSelection()?.isCollapsed === false)
1204
+ return;
1205
+ const block = this.blockOf(target);
1206
+ const anchor = block && this.anchorOf(block);
1207
+ if (anchor)
1208
+ this.openPanel(anchor);
1209
+ }
1210
+ keyOnBlock(event) {
1211
+ if (this.data === undefined)
1212
+ return;
1213
+ if (event.key === "Escape") {
1214
+ this.escape();
1215
+ return;
1216
+ }
1217
+ const block = event.target instanceof HTMLElement ? event.target : null;
1218
+ if (!block || !this.focusable.includes(block))
1219
+ return;
1220
+ if (event.key !== "Enter")
1221
+ return;
1222
+ event.preventDefault();
1223
+ const anchor = this.anchorOf(block);
1224
+ if (!anchor)
1225
+ return;
1226
+ if (this.wide)
1227
+ this.startComment(anchor);
1228
+ else
1229
+ this.openPanel(anchor);
1230
+ }
1231
+ keyInOverlay(event) {
1232
+ if (event.key === "Escape") {
1233
+ event.stopPropagation();
1234
+ this.escape();
1235
+ return;
1236
+ }
1237
+ if (event.key === "Tab" && this.dialog !== undefined)
1238
+ this.trapFocus(event);
1239
+ }
1240
+ escape() {
1241
+ if (this.dialog !== undefined)
1242
+ this.closeDialog();
1243
+ else if (this.composer !== undefined)
1244
+ this.cancelComment();
1245
+ else if (this.panel !== undefined)
1246
+ this.closePanel();
1247
+ }
1248
+ clickLink(event) {
1249
+ const target = event.composedPath()[0];
1250
+ const link = target instanceof Element ? target.closest("a[href]") : null;
1251
+ if (!link || !this.host.openLink)
1252
+ return;
1253
+ event.preventDefault();
1254
+ this.host.openLink(link.getAttribute("href") ?? "");
1255
+ }
1256
+ startComment(anchor) {
1257
+ const key = anchorKey(anchor);
1258
+ if (this.composer?.key === key) {
1259
+ this.focusOn(`comment:${key}`);
1260
+ return;
1261
+ }
1262
+ this.rememberOpener();
1263
+ this.composer = { key, anchor, target: undefined, busy: false, error: undefined };
1264
+ if (!this.wide) {
1265
+ this.panel = key;
1266
+ this.panelAnchor = anchor;
1267
+ }
1268
+ this.render();
1269
+ this.focusOn(`comment:${key}`);
1270
+ const composer = this.composer;
1271
+ this.host.commentTarget(anchor).then((target) => {
1272
+ if (this.composer !== composer)
1273
+ return;
1274
+ composer.target = target;
1275
+ this.render();
1276
+ if (target.kind !== "push-first")
1277
+ this.focusOn(`comment:${key}`);
1278
+ else
1279
+ this.focusOn("composer-cancel");
1280
+ }, () => {
1281
+ if (this.composer !== composer)
1282
+ return;
1283
+ // Nothing to say up front: try it, and the session holds it if it must.
1284
+ composer.target = { kind: "thread" };
1285
+ this.render();
1286
+ });
1287
+ }
1288
+ cancelComment() {
1289
+ const composer = this.composer;
1290
+ if (!composer)
1291
+ return;
1292
+ this.composer = undefined;
1293
+ this.drafts.delete(`comment:${composer.key}`);
1294
+ this.render();
1295
+ this.giveBackFocus();
1296
+ }
1297
+ async addComment() {
1298
+ const composer = this.composer;
1299
+ const key = composer && `comment:${composer.key}`;
1300
+ const body = key ? (this.drafts.get(key) ?? "").trim() : "";
1301
+ if (!composer || !key || composer.busy || body === "")
1302
+ return;
1303
+ composer.busy = true;
1304
+ composer.error = undefined;
1305
+ this.render();
1306
+ try {
1307
+ const thread = await this.host.comment(composer.anchor, body, this.shownText(composer.anchor));
1308
+ this.composer = undefined;
1309
+ this.drafts.delete(key);
1310
+ this.announce(thread.kind === "conversation"
1311
+ ? "Added to your review's summary. Nothing is sent until you submit the review."
1312
+ : "Added to your review. Nothing is sent until you submit the review.");
1313
+ await this.refresh();
1314
+ if (!this.focusOn(`reply:${thread.id}`))
1315
+ this.findCard(thread.id)?.focus();
1316
+ }
1317
+ catch (error) {
1318
+ composer.busy = false;
1319
+ composer.error = messageOf(error);
1320
+ if (codeOf(error) === "push-first")
1321
+ composer.target = { kind: "push-first", message: composer.error };
1322
+ this.announce(composer.error);
1323
+ this.render();
1324
+ }
1325
+ }
1326
+ async reply(thread, when) {
1327
+ const key = `reply:${thread.id}`;
1328
+ const body = (this.drafts.get(key) ?? "").trim();
1329
+ if (body === "" || this.busy.has(thread.id))
1330
+ return;
1331
+ this.busy.add(thread.id);
1332
+ this.errors.delete(key);
1333
+ this.render();
1334
+ try {
1335
+ await this.host.reply(thread.id, body, when);
1336
+ this.drafts.delete(key);
1337
+ this.busy.delete(thread.id);
1338
+ const message = when === "now" ? "Reply sent to GitHub." : "Reply added to your review.";
1339
+ this.announce(message);
1340
+ if (when === "now")
1341
+ this.host.notify?.(message);
1342
+ await this.refresh();
1343
+ this.focusOn(key);
1344
+ }
1345
+ catch (error) {
1346
+ this.busy.delete(thread.id);
1347
+ this.errors.set(key, messageOf(error));
1348
+ this.announce(messageOf(error));
1349
+ // The session refused because the review has unsent comments: read again, so the bar shows them.
1350
+ if (codeOf(error) === "reply-held")
1351
+ await this.refresh();
1352
+ else
1353
+ this.render();
1354
+ this.focusOn(key);
1355
+ }
1356
+ }
1357
+ async resolve(thread) {
1358
+ if (this.busy.has(thread.id))
1359
+ return;
1360
+ const resolved = !thread.resolved;
1361
+ this.busy.add(thread.id);
1362
+ this.render();
1363
+ try {
1364
+ await this.host.resolve(thread.id, resolved);
1365
+ this.busy.delete(thread.id);
1366
+ this.expanded.delete(thread.id);
1367
+ const message = resolved
1368
+ ? "Resolved on GitHub. Resolving is sent right away, not held with your review."
1369
+ : "Reopened on GitHub.";
1370
+ this.announce(message);
1371
+ this.host.notify?.(message);
1372
+ await this.refresh();
1373
+ if (!this.focusOn(`resolve:${thread.id}`))
1374
+ this.focusOn(`expand:${thread.id}`);
1375
+ }
1376
+ catch (error) {
1377
+ this.busy.delete(thread.id);
1378
+ this.announce(messageOf(error));
1379
+ this.host.notify?.(messageOf(error));
1380
+ this.render();
1381
+ }
1382
+ }
1383
+ async submit() {
1384
+ const data = this.data;
1385
+ if (!data || this.submitting)
1386
+ return;
1387
+ this.submitting = true;
1388
+ this.dialogError = undefined;
1389
+ this.render();
1390
+ const summary = (this.drafts.get("summary") ?? "").trim();
1391
+ try {
1392
+ await this.host.submit(this.event, summary === "" ? undefined : summary);
1393
+ this.submitting = false;
1394
+ this.drafts.delete("summary");
1395
+ this.dialog = undefined;
1396
+ const number = data.pullRequest.number;
1397
+ const done = this.event === "APPROVE"
1398
+ ? `Approved #${number}.`
1399
+ : this.event === "REQUEST_CHANGES"
1400
+ ? `Requested changes on #${number}.`
1401
+ : `Review submitted to #${number}.`;
1402
+ const message = `${done} Your comments are now on GitHub.`;
1403
+ this.event = "COMMENT";
1404
+ this.announce(message);
1405
+ this.host.notify?.(message);
1406
+ await this.refresh();
1407
+ this.giveBackFocus();
1408
+ }
1409
+ catch (error) {
1410
+ this.submitting = false;
1411
+ this.dialogError = messageOf(error);
1412
+ this.announce(this.dialogError);
1413
+ this.render();
1414
+ }
1415
+ }
1416
+ async discard() {
1417
+ try {
1418
+ await this.host.discard();
1419
+ this.dialog = undefined;
1420
+ this.discardAsk = false;
1421
+ const message = "Discarded your unsent comments.";
1422
+ this.announce(message);
1423
+ this.host.notify?.(message);
1424
+ await this.refresh();
1425
+ this.giveBackFocus();
1426
+ }
1427
+ catch (error) {
1428
+ this.discardAsk = false;
1429
+ this.dialogError = messageOf(error);
1430
+ this.announce(this.dialogError);
1431
+ this.render();
1432
+ }
1433
+ }
1434
+ /** Goes to a thread from a list: on this page, or on the page the host opens. */
1435
+ jump(summary) {
1436
+ const { thread, pages } = summary;
1437
+ this.dialog = undefined;
1438
+ this.discardAsk = false;
1439
+ if (this.goToThread(thread.id))
1440
+ return;
1441
+ this.render();
1442
+ const page = pages[0];
1443
+ if (page) {
1444
+ this.host.openThread(thread.id, page.path);
1445
+ return;
1446
+ }
1447
+ const lines = thread.lines ?? { first: 1, last: 1 };
1448
+ this.host.openSource({
1449
+ source: formatSource({ path: thread.path, first: lines.first, last: lines.last }),
1450
+ via: [],
1451
+ });
1452
+ }
1453
+ openPanel(anchor) {
1454
+ this.rememberOpener();
1455
+ this.panel = anchorKey(anchor);
1456
+ this.panelAnchor = anchor;
1457
+ this.render();
1458
+ const sheet = this.layer.querySelector(".sheet");
1459
+ (sheet?.querySelector(".thread") ?? sheet?.querySelector("button"))?.focus();
1460
+ }
1461
+ /** Opens the panel of a block with threads (which may be removed text). */
1462
+ openEntry(entry) {
1463
+ this.rememberOpener();
1464
+ this.panel = entry.key;
1465
+ this.panelAnchor = entry.removed ? undefined : entry.anchor;
1466
+ this.render();
1467
+ const sheet = this.layer.querySelector(".sheet");
1468
+ (sheet?.querySelector(".thread") ?? sheet?.querySelector("button"))?.focus();
1469
+ }
1470
+ closePanel() {
1471
+ this.panel = undefined;
1472
+ this.panelAnchor = undefined;
1473
+ this.composer = undefined;
1474
+ this.render();
1475
+ this.giveBackFocus();
1476
+ }
1477
+ openDialog(dialog) {
1478
+ if (this.data === undefined)
1479
+ return;
1480
+ this.rememberOpener();
1481
+ this.dialog = dialog;
1482
+ this.discardAsk = false;
1483
+ this.dialogError = undefined;
1484
+ this.render();
1485
+ this.focusDialog();
1486
+ // The lists need every thread; read them again, since they may have changed.
1487
+ void this.host.allThreads().then((all) => {
1488
+ if (this.disposed)
1489
+ return;
1490
+ this.all = all;
1491
+ if (this.dialog === dialog) {
1492
+ const focus = this.focusKey();
1493
+ this.render();
1494
+ if (!this.restoreFocus(focus))
1495
+ this.focusDialog();
1496
+ }
1497
+ }, () => undefined);
1498
+ }
1499
+ closeDialog() {
1500
+ this.dialog = undefined;
1501
+ this.discardAsk = false;
1502
+ this.render();
1503
+ this.giveBackFocus();
1504
+ }
1505
+ focusDialog() {
1506
+ const dialog = this.layer.querySelector(".dialog");
1507
+ const first = dialog?.querySelector(this.dialog === "submit" ? "input[type=radio]:checked" : "[aria-pressed=true]");
1508
+ first?.focus();
1509
+ }
1510
+ trapFocus(event) {
1511
+ const dialog = this.layer.querySelector(".dialog");
1512
+ if (!dialog)
1513
+ return;
1514
+ const focusable = Array.from(dialog.querySelectorAll("button, textarea, input, [tabindex]")).filter((el) => !el.disabled && el.tabIndex >= 0);
1515
+ const first = focusable[0];
1516
+ const last = focusable.at(-1);
1517
+ const active = this.layer.activeElement;
1518
+ if (event.shiftKey && active === first) {
1519
+ event.preventDefault();
1520
+ last?.focus();
1521
+ }
1522
+ else if (!event.shiftKey && active === last) {
1523
+ event.preventDefault();
1524
+ first?.focus();
1525
+ }
1526
+ }
1527
+ // --- Focus ---
1528
+ rememberOpener() {
1529
+ if (this.opener !== undefined)
1530
+ return;
1531
+ const active = this.activeElement();
1532
+ this.opener = active instanceof HTMLElement ? active : undefined;
1533
+ }
1534
+ giveBackFocus() {
1535
+ const opener = this.opener;
1536
+ this.opener = undefined;
1537
+ if (opener?.isConnected)
1538
+ opener.focus({ preventScroll: true });
1539
+ }
1540
+ /** The focused element, inside the overlay's shadow roots or out. */
1541
+ activeElement() {
1542
+ let active = this.doc.activeElement;
1543
+ while (active?.shadowRoot?.activeElement)
1544
+ active = active.shadowRoot.activeElement;
1545
+ return active;
1546
+ }
1547
+ focusKey() {
1548
+ const active = this.activeElement();
1549
+ const key = active instanceof HTMLElement ? active.dataset["focus"] : undefined;
1550
+ if (key === undefined || !active)
1551
+ return undefined;
1552
+ const field = active;
1553
+ return {
1554
+ key,
1555
+ start: typeof field.selectionStart === "number" ? field.selectionStart : 0,
1556
+ end: typeof field.selectionEnd === "number" ? field.selectionEnd : 0,
1557
+ };
1558
+ }
1559
+ restoreFocus(focus) {
1560
+ if (!focus)
1561
+ return false;
1562
+ const el = this.byFocusKey(focus.key);
1563
+ if (!el || el.disabled)
1564
+ return false;
1565
+ el.focus({ preventScroll: true });
1566
+ if (el instanceof HTMLTextAreaElement)
1567
+ el.setSelectionRange(focus.start, focus.end);
1568
+ return true;
1569
+ }
1570
+ byFocusKey(key) {
1571
+ for (const shadow of [this.layer, this.before, this.after, this.tools]) {
1572
+ for (const el of Array.from(shadow.querySelectorAll("[data-focus]"))) {
1573
+ if (el.dataset["focus"] === key)
1574
+ return el;
1575
+ }
1576
+ }
1577
+ return undefined;
1578
+ }
1579
+ focusOn(key) {
1580
+ const el = this.byFocusKey(key);
1581
+ if (!el || el.disabled)
1582
+ return false;
1583
+ el.focus({ preventScroll: true });
1584
+ return true;
1585
+ }
1586
+ findCard(threadId) {
1587
+ for (const shadow of [this.layer, this.before]) {
1588
+ for (const el of Array.from(shadow.querySelectorAll(".thread"))) {
1589
+ if (el.dataset["thread"] === threadId)
1590
+ return el;
1591
+ }
1592
+ }
1593
+ return undefined;
1594
+ }
1595
+ announce(message) {
1596
+ // A change of text is what's announced: clear it first, so the same message is said again.
1597
+ this.live.textContent = "";
1598
+ requestFrame(this.doc, () => {
1599
+ this.live.textContent = message;
1600
+ });
1601
+ }
1602
+ // --- Elements ---
1603
+ el(tag, className, text) {
1604
+ const el = this.doc.createElement(tag);
1605
+ if (className)
1606
+ el.className = className;
1607
+ if (text !== undefined)
1608
+ el.textContent = text;
1609
+ return el;
1610
+ }
1611
+ button(className, text, onClick, focusKey) {
1612
+ const button = this.el("button", className, text);
1613
+ button.type = "button";
1614
+ if (focusKey !== undefined)
1615
+ button.dataset["focus"] = focusKey;
1616
+ button.addEventListener("click", (event) => {
1617
+ event.stopPropagation();
1618
+ onClick();
1619
+ });
1620
+ return button;
1621
+ }
1622
+ badge(kind, text) {
1623
+ return this.el("span", `badge ${kind}`, text);
1624
+ }
1625
+ }
1626
+ const HELD_REASON = "You have unsent comments, so GitHub adds replies to your review. Submit or discard it to reply at once.";
1627
+ /** `rollouts.md:28`, from a block's anchor. */
1628
+ function sourceLabel(anchor) {
1629
+ const colon = anchor.source.lastIndexOf(":");
1630
+ const match = /^(\d+)-(\d+)$/.exec(anchor.source.slice(colon + 1));
1631
+ if (colon < 0 || !match)
1632
+ return anchor.source;
1633
+ let path = anchor.source.slice(0, colon);
1634
+ try {
1635
+ path = decodeURIComponent(path);
1636
+ }
1637
+ catch {
1638
+ // Show it as it is.
1639
+ }
1640
+ return linesLabel(path, { first: Number(match[1]), last: Number(match[2]) });
1641
+ }
1642
+ /** A comment's first line, for a list. */
1643
+ function oneLine(body) {
1644
+ return body.replace(/\s+/g, " ").trim();
1645
+ }
1646
+ function messageOf(error) {
1647
+ if (typeof error === "object" && error !== null && "message" in error) {
1648
+ return String(error.message);
1649
+ }
1650
+ return String(error);
1651
+ }
1652
+ function codeOf(error) {
1653
+ if (typeof error === "object" && error !== null && "code" in error) {
1654
+ const code = error.code;
1655
+ return typeof code === "string" ? code : undefined;
1656
+ }
1657
+ return undefined;
1658
+ }
1659
+ /** An element that can't hold children: the overlay's things go after it. */
1660
+ function isVoid(element) {
1661
+ return /^(img|hr|br|input|video|audio|iframe|embed|object|svg|math|canvas)$/.test(element.localName);
1662
+ }
1663
+ function cssId(text) {
1664
+ return text.replace(/[^A-Za-z0-9_-]/g, "_");
1665
+ }
1666
+ function requestFrame(doc, callback) {
1667
+ const view = doc.defaultView;
1668
+ if (view && typeof view.requestAnimationFrame === "function")
1669
+ view.requestAnimationFrame(callback);
1670
+ else
1671
+ setTimeout(callback, 0);
1672
+ }
1673
+ let sheet;
1674
+ /**
1675
+ * Gives a shadow root the overlay's stylesheet: one constructed stylesheet,
1676
+ * shared, where the browser has them (a content security policy that allows
1677
+ * no inline styles still allows these); a `<style>` element elsewhere.
1678
+ */
1679
+ function adoptStyles(shadow, doc) {
1680
+ const view = doc.defaultView;
1681
+ if (view?.CSSStyleSheet && "adoptedStyleSheets" in shadow) {
1682
+ try {
1683
+ if (sheet?.doc !== doc) {
1684
+ const constructed = new view.CSSStyleSheet();
1685
+ constructed.replaceSync(OVERLAY_CSS);
1686
+ sheet = { doc, sheet: constructed };
1687
+ }
1688
+ shadow.adoptedStyleSheets = [sheet.sheet];
1689
+ return;
1690
+ }
1691
+ catch {
1692
+ // Not constructable here: fall back to an element.
1693
+ }
1694
+ }
1695
+ const style = doc.createElement("style");
1696
+ style.setAttribute("data-overlay", "");
1697
+ style.textContent = OVERLAY_CSS;
1698
+ shadow.append(style);
1699
+ }
1700
+ /** Where a thread's Open source goes: its own lines when it has them, else its block's. */
1701
+ function threadSource(thread, entry) {
1702
+ if (thread.lines === undefined || (thread.kind === "review" && thread.side === "LEFT")) {
1703
+ return entry.anchor;
1704
+ }
1705
+ return {
1706
+ source: formatSource({ path: thread.path, first: thread.lines.first, last: thread.lines.last }),
1707
+ via: entry.anchor.via,
1708
+ };
1709
+ }
1710
+ //# sourceMappingURL=overlay.js.map