@nuniapp/widget 0.1.8 → 0.1.10

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/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as REACTIONS, i as LIMITS, n as MASK_ATTRIBUTE, o as generateSecret, r as MASK_SELECTOR, s as isProjectId, t as CAPTURE_MARK } from "./mark-B8wojiFu.js";
1
+ import { _ as isProjectId, a as initials, c as IMAGE_TYPES, d as CAPTURE_MARK, f as MASK_ATTRIBUTE, g as generateSecret, h as REACTIONS, i as icon, m as LIMITS, n as copyText, o as parseColor, p as MASK_SELECTOR, r as h, s as readableOn, t as colorFor, u as imageFromFile } from "./dom-BWcT-q7S.js";
2
2
  //#region ../shared/src/path.ts
3
3
  /**
4
4
  * Normalize a URL to the key comments are matched by. Query and hash are
@@ -120,7 +120,16 @@ function buildCommentPrompt(comment, options = {}) {
120
120
  const includeContext = options.includeContext ?? true;
121
121
  const { anchor, page } = comment;
122
122
  const out = [];
123
- out.push("Fix this piece of feedback that was left on the live site with Nuni.", "", "## Comment", "", quote(comment.body), "", `From ${comment.authorName} on ${new Date(comment.createdAt).toISOString().slice(0, 10)}` + (comment.status === "resolved" ? " (already resolved)" : ""));
123
+ out.push("Fix this piece of feedback that was left on the live site with Nuni.", "", "## Comment", "", ...comment.body ? [quote(comment.body), ""] : [], ...comment.suggestion ? [
124
+ "Suggested text change. Replace:",
125
+ "",
126
+ quote(comment.suggestion.before),
127
+ "",
128
+ "With:",
129
+ "",
130
+ quote(comment.suggestion.after),
131
+ ""
132
+ ] : [], `From ${comment.authorName} on ${new Date(comment.createdAt).toISOString().slice(0, 10)}` + (comment.status === "resolved" ? comment.resolution === "outdated" ? " (closed as outdated)" : " (already resolved)" : ""));
124
133
  if (comment.replies?.length) {
125
134
  out.push("", `## Replies (${comment.replies.length})`, "");
126
135
  for (const reply of comment.replies) out.push(`${reply.authorName}${reply.isOwner ? " (owner)" : ""}:`, quote(reply.body), "");
@@ -132,6 +141,7 @@ function buildCommentPrompt(comment, options = {}) {
132
141
  out.push(`- Viewport: ${comment.viewport.w}×${comment.viewport.h} at ${comment.viewport.dpr}x`);
133
142
  if (includeContext && comment.userAgent) out.push(`- Browser: ${comment.userAgent}`);
134
143
  out.push("", "## Element", "");
144
+ if (comment.pinLostAt) out.push(`- Not found on the page since ${new Date(comment.pinLostAt).toISOString().slice(0, 10)}: it may have changed or been removed. Check whether the feedback still applies.`);
135
145
  out.push(`- Tag: <${anchor.tag}>`);
136
146
  if (anchor.text) out.push(`- Text: ${inline(anchor.text)}`);
137
147
  if (anchor.quote) out.push(`- Selected text: ${inline(anchor.quote.exact)}`);
@@ -159,13 +169,14 @@ function buildCommentPrompt(comment, options = {}) {
159
169
  out.push("", `## Failed requests (${context.network.length})`, "");
160
170
  for (const r of context.network) out.push(`- ${r.method} ${r.url} → ${r.status === 0 ? "network error" : r.status}`);
161
171
  }
172
+ if (comment.imageUrls?.length) out.push("", `## Images from ${comment.authorName} (${comment.imageUrls.length})`, "", ...comment.imageUrls.map((url) => `- ${url}`));
162
173
  if (includeContext && comment.screenshotUrl) out.push("", "## Screenshot", "", comment.screenshotUrl);
163
- out.push("", "## Steps", "", "1. Find the code that renders this element. Search for the text, the component name, the test ID or the selector.", "2. Make the change the comment asks for, and keep the fix focused on it.", "3. Tell me what you changed, so the comment can be resolved in Nuni.", ` If the Nuni CLI is signed in to this project (\`npx ${PACKAGES.cli}@latest login\`), resolve it with \`npx ${PACKAGES.cli}@latest resolve ${comment._id}\` or the \`resolve_comment\` MCP tool.`);
174
+ out.push("", "## Steps", "", "1. Find the code that renders this element. Search for the text, the component name, the test ID or the selector.", comment.suggestion ? "2. Replace the text exactly as suggested (wherever it comes from: markup, a component, a translation or content file), and keep the fix focused on it." : "2. Make the change the comment asks for, and keep the fix focused on it.", "3. Tell me what you changed, so the comment can be resolved in Nuni.", ` If the Nuni CLI is signed in to this project (\`npx ${PACKAGES.cli}@latest login\`), resolve it with \`npx ${PACKAGES.cli}@latest resolve ${comment._id}\` or the \`resolve_comment\` MCP tool.`);
164
175
  return out.join("\n");
165
176
  }
166
177
  //#endregion
167
178
  //#region src/config.ts
168
- const VERSION = "0.1.8";
179
+ const VERSION = "0.1.10";
169
180
  function resolveConfig(options) {
170
181
  return {
171
182
  project: options.project,
@@ -181,6 +192,7 @@ function resolveConfig(options) {
181
192
  zIndex: typeof options.zIndex === "number" && Number.isFinite(options.zIndex) ? Math.round(options.zIndex) : null,
182
193
  locale: options.locale?.trim() || void 0,
183
194
  messages: options.messages ?? {},
195
+ images: options.images ?? true,
184
196
  capture: {
185
197
  console: options.capture?.console ?? true,
186
198
  network: options.capture?.network ?? true,
@@ -5272,6 +5284,13 @@ var NuniApi = class {
5272
5284
  path
5273
5285
  }, (comments) => cb(comments), onError);
5274
5286
  }
5287
+ /** The visitor's own comments on every page, live. */
5288
+ onMine(authorSecret, cb) {
5289
+ return this.client.onUpdate(api.comments.listMine, {
5290
+ publicId: this.config.project,
5291
+ authorSecret
5292
+ }, (comments) => cb(comments), () => {});
5293
+ }
5275
5294
  onPages(cb) {
5276
5295
  return this.client.onUpdate(api.comments.pagesWithComments, { publicId: this.config.project }, cb, () => {});
5277
5296
  }
@@ -5340,9 +5359,16 @@ var NuniApi = class {
5340
5359
  if (!response.ok || !data.id) throw new NuniApiError(data.message ?? "Couldn't post the reply", data.code ?? "error");
5341
5360
  return data.id;
5342
5361
  }
5343
- /** Attach a screenshot to the author's own, just-posted comment. */
5344
- async uploadScreenshot(id, authorSecret, image) {
5345
- const response = await fetch(`${this.config.convexSiteUrl}/widget/screenshot`, {
5362
+ /** Attach the element's screenshot (for the owner) to a just-posted comment. */
5363
+ uploadScreenshot(id, authorSecret, image) {
5364
+ return this.upload("screenshot", id, authorSecret, image);
5365
+ }
5366
+ /** An image for everyone: picked, pasted or a marked-up screenshot. */
5367
+ uploadImage(id, authorSecret, image) {
5368
+ return this.upload("image", id, authorSecret, image);
5369
+ }
5370
+ async upload(kind, id, authorSecret, image) {
5371
+ const response = await fetch(`${this.config.convexSiteUrl}/widget/${kind}`, {
5346
5372
  method: "POST",
5347
5373
  headers: {
5348
5374
  "Content-Type": image.type,
@@ -5354,7 +5380,7 @@ var NuniApi = class {
5354
5380
  });
5355
5381
  if (!response.ok) {
5356
5382
  const data = await response.json().catch(() => ({}));
5357
- throw new NuniApiError(data.message ?? "Couldn't attach the screenshot", data.code ?? "error");
5383
+ throw new NuniApiError(data.message ?? "Couldn't attach the image", data.code ?? "error");
5358
5384
  }
5359
5385
  }
5360
5386
  async run(promise) {
@@ -5364,11 +5390,12 @@ var NuniApi = class {
5364
5390
  throw new NuniApiError(errorMessage(error), "error");
5365
5391
  }
5366
5392
  }
5367
- editOwn(id, authorSecret, body) {
5393
+ editOwn(id, authorSecret, body, suggestion) {
5368
5394
  return this.run(this.client.mutation(api.comments.editOwn, {
5369
5395
  id,
5370
5396
  authorSecret,
5371
- body
5397
+ body,
5398
+ suggestion
5372
5399
  }));
5373
5400
  }
5374
5401
  deleteOwn(id, authorSecret) {
@@ -5377,12 +5404,40 @@ var NuniApi = class {
5377
5404
  authorSecret
5378
5405
  }));
5379
5406
  }
5380
- resolve(id, sessionToken) {
5407
+ resolve(id, sessionToken, resolution) {
5381
5408
  return this.run(this.client.mutation(api.comments.resolve, {
5382
5409
  id,
5410
+ resolution,
5383
5411
  sessionToken
5384
5412
  }));
5385
5413
  }
5414
+ /** The owner points a comment at another element on its page. */
5415
+ repin(id, anchor, sessionToken) {
5416
+ return this.run(this.client.mutation(api.comments.repin, {
5417
+ id,
5418
+ anchor,
5419
+ sessionToken
5420
+ }));
5421
+ }
5422
+ /**
5423
+ * Tell the backend which pins on this page couldn't be found, or were
5424
+ * found again, so the owner can see them. Best effort.
5425
+ */
5426
+ async reportPins(path, lost, found) {
5427
+ try {
5428
+ await fetch(`${this.config.convexSiteUrl}/widget/pins`, {
5429
+ method: "POST",
5430
+ headers: { "Content-Type": "application/json" },
5431
+ body: JSON.stringify({
5432
+ publicId: this.config.project,
5433
+ path,
5434
+ lost,
5435
+ found
5436
+ }),
5437
+ keepalive: true
5438
+ });
5439
+ } catch {}
5440
+ }
5386
5441
  reopen(id, sessionToken) {
5387
5442
  return this.run(this.client.mutation(api.comments.reopen, {
5388
5443
  id,
@@ -5425,106 +5480,6 @@ var NuniApi = class {
5425
5480
  }
5426
5481
  };
5427
5482
  //#endregion
5428
- //#region src/dom.ts
5429
- /** Tiny element factory. Strings become text nodes (never HTML). */
5430
- function h(tag, attrs = {}, ...children) {
5431
- const el = document.createElement(tag);
5432
- for (const [key, value] of Object.entries(attrs)) {
5433
- if (value == null || value === false) continue;
5434
- if (key.startsWith("on") && typeof value === "function") el.addEventListener(key.slice(2).toLowerCase(), value);
5435
- else if (key === "class") el.className = String(value);
5436
- else if (value === true) el.setAttribute(key, "");
5437
- else el.setAttribute(key, String(value));
5438
- }
5439
- for (const child of children.flat()) {
5440
- if (child == null || child === false) continue;
5441
- el.append(typeof child === "string" ? document.createTextNode(child) : child);
5442
- }
5443
- return el;
5444
- }
5445
- /** Renders one of the constant SVG strings in icons.ts (never user input). */
5446
- function icon(svg) {
5447
- const span = document.createElement("span");
5448
- span.className = "icon";
5449
- span.setAttribute("aria-hidden", "true");
5450
- const parsed = new DOMParser().parseFromString(svg, "image/svg+xml");
5451
- span.append(document.importNode(parsed.documentElement, true));
5452
- return span;
5453
- }
5454
- function initials(name) {
5455
- const parts = name.trim().split(/\s+/).filter(Boolean);
5456
- return (parts.length > 1 ? parts[0][0] + parts[parts.length - 1][0] : (parts[0] ?? "?").slice(0, 2)).toUpperCase();
5457
- }
5458
- /** Stable pleasant color per author name. */
5459
- function colorFor(name) {
5460
- let hash = 0;
5461
- for (const ch of name) hash = hash * 31 + ch.charCodeAt(0) >>> 0;
5462
- return `oklch(0.5 0.17 ${hash % 360})`;
5463
- }
5464
- /** Copy text, falling back to execCommand where the async clipboard is off (plain http previews). */
5465
- async function copyText(text) {
5466
- try {
5467
- await navigator.clipboard.writeText(text);
5468
- return true;
5469
- } catch {}
5470
- const area = document.createElement("textarea");
5471
- area.value = text;
5472
- area.setAttribute("readonly", "");
5473
- area.style.cssText = "position:fixed;top:0;left:0;opacity:0";
5474
- document.body.appendChild(area);
5475
- area.select();
5476
- try {
5477
- return document.execCommand("copy");
5478
- } catch {
5479
- return false;
5480
- } finally {
5481
- area.remove();
5482
- }
5483
- }
5484
- /** Any CSS color as RGB, using the browser's own parser. */
5485
- function parseColor(color) {
5486
- const probe = document.createElement("span");
5487
- probe.style.color = color;
5488
- if (!probe.style.color) return null;
5489
- probe.style.display = "none";
5490
- document.documentElement.appendChild(probe);
5491
- const computed = getComputedStyle(probe).color;
5492
- probe.remove();
5493
- if (/^rgba?\(/.test(computed)) {
5494
- const parts = computed.match(/[\d.]+/g)?.map(Number);
5495
- if (parts && parts.length >= 3) return [
5496
- parts[0],
5497
- parts[1],
5498
- parts[2]
5499
- ];
5500
- }
5501
- try {
5502
- const canvas = document.createElement("canvas");
5503
- canvas.width = canvas.height = 1;
5504
- const ctx = canvas.getContext("2d");
5505
- if (!ctx) return null;
5506
- ctx.fillStyle = color;
5507
- ctx.fillRect(0, 0, 1, 1);
5508
- const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
5509
- return [
5510
- r,
5511
- g,
5512
- b
5513
- ];
5514
- } catch {
5515
- return null;
5516
- }
5517
- }
5518
- /** Black or white text, whichever reads better on the color (WCAG). */
5519
- function readableOn([r, g, b]) {
5520
- const channel = (c) => {
5521
- const v = c / 255;
5522
- return v <= .03928 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4;
5523
- };
5524
- const l = .2126 * channel(r) + .7152 * channel(g) + .0722 * channel(b);
5525
- return 1.05 / (l + .05) >= (l + .05) / .05 ? "#ffffff" : "#111111";
5526
- }
5527
- //#endregion
5528
5483
  //#region src/i18n.ts
5529
5484
  /**
5530
5485
  * Every word the widget shows. Override any of them with the `messages`
@@ -5552,6 +5507,11 @@ const EN = {
5552
5507
  close: "Close",
5553
5508
  tabOpen: "Open ({count})",
5554
5509
  tabResolved: "Resolved ({count})",
5510
+ tabYours: "Yours ({count})",
5511
+ yours: "Yours",
5512
+ noYours: "Comments you leave on any page of this site show here.",
5513
+ thisPage: "This page",
5514
+ statusOpen: "Open",
5555
5515
  loading: "Loading…",
5556
5516
  noOpen: "No open comments on this page.",
5557
5517
  noOpenKey: "No open comments on this page. Press {key} to add one.",
@@ -5580,6 +5540,25 @@ const EN = {
5580
5540
  one: "{count} character left",
5581
5541
  other: "{count} characters left"
5582
5542
  },
5543
+ suggestEdit: "Suggest an edit",
5544
+ suggestedEdit: "Suggested edit",
5545
+ removeSuggestion: "Remove",
5546
+ newText: "New text",
5547
+ addNote: "Add a note (optional)",
5548
+ addImage: "Add an image",
5549
+ markUpScreenshot: "Mark up a screenshot",
5550
+ preparingScreenshot: "Taking the screenshot…",
5551
+ markUpTitle: "Mark up the screenshot",
5552
+ markUpBox: "Box",
5553
+ markUpArrow: "Arrow",
5554
+ markUpPen: "Pen",
5555
+ markUpUndo: "Undo",
5556
+ markUpDone: "Attach",
5557
+ markUpFailed: "Couldn't save the drawing as an image. Try again.",
5558
+ imagesPublic: "Images are shown to everyone who can see this comment.",
5559
+ imageN: "Image {n}",
5560
+ removeImage: "Remove image {n}",
5561
+ openImage: "Open the image",
5583
5562
  pinLabel: "Comment by {name}: {body}",
5584
5563
  commentBy: "Comment by {name}",
5585
5564
  openScreenshot: "Open the screenshot",
@@ -5593,6 +5572,15 @@ const EN = {
5593
5572
  resolve: "Resolve",
5594
5573
  reopen: "Reopen",
5595
5574
  copyForAgent: "Copy for agent",
5575
+ copyNewText: "Copy new text",
5576
+ movePin: "Move pin",
5577
+ movePinHint: "Click the element this comment is about",
5578
+ pinMoved: "Pin moved",
5579
+ closeOutdated: "Close as outdated",
5580
+ closedOutdated: "Closed as outdated",
5581
+ outdated: "Outdated",
5582
+ lostHint: "This element isn't on the page any more. Move the pin to where it is now, or close the comment if it no longer applies.",
5583
+ newTextCopied: "New text copied",
5596
5584
  copyForAgentHint: "Copy for your coding agent",
5597
5585
  loadingContext: "Loading page context…",
5598
5586
  edit: "Edit",
@@ -5619,6 +5607,12 @@ const EN = {
5619
5607
  linkGone: "That comment was deleted or is on another page",
5620
5608
  enterName: "Enter your name",
5621
5609
  writeFirst: "Write a comment first",
5610
+ changeTextFirst: "Change the text to suggest an edit",
5611
+ tooManyImages: "Add up to {count} images",
5612
+ imageUnreadable: "Attach a PNG, JPEG or WebP image",
5613
+ imageFailed: "Couldn't attach an image",
5614
+ noScreenshot: "Couldn't take a screenshot of this element",
5615
+ suggestionTooLong: "Suggestions are limited to {count} characters",
5622
5616
  commentTooLong: "Comments are limited to {count} characters",
5623
5617
  replyTooLong: "Replies are limited to {count} characters",
5624
5618
  postFailed: "Couldn't post the comment",
@@ -5696,6 +5690,8 @@ const ICONS = {
5696
5690
  arrowUp: s("<path d=\"M12 19V5M5 12l7-7 7 7\"/>"),
5697
5691
  search: s("<circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m21 21-4.3-4.3\"/>"),
5698
5692
  target: s("<circle cx=\"12\" cy=\"12\" r=\"9\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/>"),
5693
+ image: s("<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\"/><circle cx=\"9\" cy=\"9\" r=\"2\"/><path d=\"m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21\"/>"),
5694
+ pen: s("<path d=\"m12 19 7-7 3 3-7 7z\"/><path d=\"m18 13-1.5-7.5L2 2l3.5 14.5L13 18z\"/><path d=\"m2 2 7.6 7.6\"/><circle cx=\"11\" cy=\"11\" r=\"2\"/>"),
5699
5695
  github: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M12 .5a11.5 11.5 0 0 0-3.6 22.4c.6.1.8-.3.8-.6v-2c-3.2.7-3.9-1.5-3.9-1.5-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2a11 11 0 0 1 5.8 0c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.9 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A11.5 11.5 0 0 0 12 .5z"/></svg>`,
5700
5696
  link: s("<path d=\"M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6\"/>")
5701
5697
  };
@@ -6513,7 +6509,90 @@ textarea.field { resize: vertical; min-height: 72px; max-height: 240px; }
6513
6509
  white-space: nowrap;
6514
6510
  }
6515
6511
  .cmp-link:hover { color: var(--n-fg); }
6516
- .composer .quote { margin: 6px 14px 0; }
6512
+ .composer .quote, .composer .suggest { margin: 6px 14px 0; }
6513
+ .suggest {
6514
+ display: grid;
6515
+ gap: 4px;
6516
+ padding: 8px 10px;
6517
+ border-radius: 8px;
6518
+ background: var(--n-bg-subtle);
6519
+ white-space: pre-wrap;
6520
+ overflow-wrap: anywhere;
6521
+ }
6522
+ .suggest-label {
6523
+ display: flex;
6524
+ align-items: center;
6525
+ gap: 6px;
6526
+ min-height: 20px;
6527
+ color: var(--n-fg-muted);
6528
+ font-size: 12px;
6529
+ font-weight: 550;
6530
+ }
6531
+ .suggest .cmp-link { margin-left: auto; padding: 0 4px; }
6532
+ .suggest del, .item-suggest del { color: var(--n-fg-muted); text-decoration-color: var(--n-danger); }
6533
+ .suggest ins, .item-suggest ins {
6534
+ color: var(--n-fg);
6535
+ text-decoration: none;
6536
+ background: var(--n-accent-soft);
6537
+ border-radius: 4px;
6538
+ }
6539
+ .suggest ins { padding: 1px 3px; justify-self: start; }
6540
+ .cmp-suggest {
6541
+ display: block;
6542
+ width: 100%;
6543
+ max-height: 160px;
6544
+ padding: 4px 6px;
6545
+ border: 1px solid var(--n-border);
6546
+ border-radius: 6px;
6547
+ background: var(--n-bg);
6548
+ outline: none;
6549
+ resize: none;
6550
+ line-height: 1.4;
6551
+ }
6552
+ .cmp-suggest:focus { border-color: var(--n-accent); }
6553
+ .cmp-suggest-btn { display: inline-flex; align-items: center; gap: 4px; }
6554
+ .lost {
6555
+ display: grid;
6556
+ gap: 8px;
6557
+ padding: 10px;
6558
+ border: 1px dashed var(--n-border);
6559
+ border-radius: 8px;
6560
+ color: var(--n-fg-muted);
6561
+ font-size: 13px;
6562
+ }
6563
+ .lost p { margin: 0; }
6564
+ .cmp-images { display: grid; gap: 4px; padding: 6px 14px 0; }
6565
+ .cmp-thumbs { display: flex; flex-wrap: wrap; gap: 6px; }
6566
+ .cmp-thumb { position: relative; }
6567
+ .cmp-thumb img, .images img {
6568
+ display: block;
6569
+ width: 64px;
6570
+ height: 48px;
6571
+ object-fit: cover;
6572
+ border: 1px solid var(--n-border);
6573
+ border-radius: 6px;
6574
+ background: var(--n-bg-subtle);
6575
+ }
6576
+ .cmp-thumb-remove {
6577
+ position: absolute;
6578
+ top: -6px;
6579
+ right: -6px;
6580
+ display: grid;
6581
+ place-items: center;
6582
+ width: 20px;
6583
+ height: 20px;
6584
+ padding: 0;
6585
+ border: 1px solid var(--n-border);
6586
+ border-radius: 999px;
6587
+ background: var(--n-bg);
6588
+ color: var(--n-fg-muted);
6589
+ }
6590
+ .cmp-thumb-remove .icon { width: 12px; height: 12px; }
6591
+ .cmp-images-note { margin: 0; color: var(--n-fg-muted); font-size: 12px; }
6592
+ .images { display: flex; flex-wrap: wrap; gap: 6px; }
6593
+ .images img { width: 88px; height: 64px; }
6594
+ .lost .row { gap: 6px; flex-wrap: wrap; }
6595
+ .cmp-suggest-btn .icon { width: 13px; height: 13px; }
6517
6596
  .cmp-text {
6518
6597
  display: block;
6519
6598
  width: 100%;
@@ -6667,7 +6746,35 @@ a.item { color: inherit; text-decoration: none; }
6667
6746
  }
6668
6747
  `;
6669
6748
  //#endregion
6749
+ //#region src/suggestion.ts
6750
+ /**
6751
+ * The text a comment can suggest new words for: the selected words, or the
6752
+ * whole text of a small element (a button, a link, a heading) with no other
6753
+ * text inside it. Null for areas, form fields and larger blocks.
6754
+ */
6755
+ function suggestableText(anchor, element) {
6756
+ if (anchor.quote) return anchor.quote.exact;
6757
+ if (anchor.region || !anchor.text) return null;
6758
+ if (/^(INPUT|TEXTAREA|SELECT)$/.test(element.tagName)) return null;
6759
+ if (anchor.text.length >= LIMITS.anchorTextMaxLength) return null;
6760
+ for (const child of element.querySelectorAll("*")) if (elementText(child)) return null;
6761
+ return anchor.text;
6762
+ }
6763
+ /** The old words struck through and the new ones, under a small label. */
6764
+ function renderSuggestion(suggestion, label, action) {
6765
+ return h("div", { class: "suggest" }, h("div", { class: "suggest-label" }, label, action), h("del", {}, suggestion.before), h("ins", {}, suggestion.after));
6766
+ }
6767
+ /** One line for the panel: old → new. */
6768
+ function renderSuggestionLine(suggestion) {
6769
+ return h("div", { class: "item-quote item-suggest" }, h("del", {}, suggestion.before), " → ", h("ins", {}, suggestion.after));
6770
+ }
6771
+ //#endregion
6670
6772
  //#region src/widget.ts
6773
+ const PANEL_TABS = [
6774
+ "open",
6775
+ "resolved",
6776
+ "mine"
6777
+ ];
6671
6778
  const PICKING_CLASS = "nuni-picking";
6672
6779
  const PICK_EVENTS = [
6673
6780
  "pointerdown",
@@ -6710,6 +6817,14 @@ function frameDocuments(doc, depth = 0) {
6710
6817
  return out;
6711
6818
  }
6712
6819
  const MOBILE_QUERY = "(max-width: 640px)";
6820
+ /** How long pins have to stay settled before lost ones are reported. */
6821
+ const PIN_REPORT_DELAY_MS = 3e3;
6822
+ /** Drawn on screenshots: stands out on most pages, whatever the accent. */
6823
+ const MARKUP_COLOR = "#e5484d";
6824
+ /** The previews of a comment box's images are no longer shown. */
6825
+ function revokeImages(draft) {
6826
+ for (const image of draft.images ?? []) URL.revokeObjectURL(image.url);
6827
+ }
6713
6828
  function isTypingTarget(target) {
6714
6829
  if (!target || target.nodeType !== 1) return false;
6715
6830
  const el = target;
@@ -6729,6 +6844,17 @@ function describeElement(el) {
6729
6844
  return `${tag}${id}${cls}${text ? ` "${text}"` : ""}`;
6730
6845
  }
6731
6846
  /**
6847
+ * A link to one comment on another page of this site: the page, opened
6848
+ * with that comment focused. Null when the page can't be linked to.
6849
+ */
6850
+ function commentHref(c, loc) {
6851
+ const page = otherPageHref(c.page.path, loc);
6852
+ if (page === null) return null;
6853
+ const url = new URL(page);
6854
+ url.searchParams.set("nuni", c._id);
6855
+ return url.toString();
6856
+ }
6857
+ /**
6732
6858
  * Build an "Other pages" link target, or null when it would leave the
6733
6859
  * current origin. The stored path comes from anonymous comment submissions;
6734
6860
  * one that does not start with "/" (".evil.com/x", "@evil.com/") would
@@ -6754,6 +6880,8 @@ var NuniWidget = class {
6754
6880
  _defineProperty(this, "overlayLayer", void 0);
6755
6881
  _defineProperty(this, "selectButton", void 0);
6756
6882
  _defineProperty(this, "uiLayer", void 0);
6883
+ _defineProperty(this, "modalLayer", void 0);
6884
+ _defineProperty(this, "markingUp", false);
6757
6885
  _defineProperty(this, "api", void 0);
6758
6886
  _defineProperty(this, "cleanups", []);
6759
6887
  _defineProperty(this, "pageUnsub", null);
@@ -6768,6 +6896,11 @@ var NuniWidget = class {
6768
6896
  _defineProperty(this, "picking", false);
6769
6897
  _defineProperty(this, "panelOpen", false);
6770
6898
  _defineProperty(this, "tab", "open");
6899
+ _defineProperty(this, "mine", null);
6900
+ _defineProperty(this, "mineUnsub", null);
6901
+ _defineProperty(this, "repinId", null);
6902
+ _defineProperty(this, "pinReported", /* @__PURE__ */ new Map());
6903
+ _defineProperty(this, "pinReportTimer", 0);
6771
6904
  _defineProperty(this, "search", "");
6772
6905
  _defineProperty(this, "lastViewedId", void 0);
6773
6906
  _defineProperty(this, "card", null);
@@ -6906,6 +7039,7 @@ var NuniWidget = class {
6906
7039
  this.pinLayer = h("div", { class: "pins" });
6907
7040
  this.overlayLayer = h("div", { class: "overlay" });
6908
7041
  this.uiLayer = h("div", { class: "ui" });
7042
+ this.modalLayer = h("div", { class: "modal" });
6909
7043
  this.selectButton = h("button", {
6910
7044
  class: "select-btn",
6911
7045
  type: "button",
@@ -6920,7 +7054,7 @@ var NuniWidget = class {
6920
7054
  role: "status",
6921
7055
  "aria-live": "polite"
6922
7056
  });
6923
- this.root.append(style, this.markLayer, this.pinLayer, this.overlayLayer, this.uiLayer, this.selectButton, this.live);
7057
+ this.root.append(style, this.markLayer, this.pinLayer, this.overlayLayer, this.uiLayer, this.modalLayer, this.selectButton, this.live);
6924
7058
  this.uiLayer.addEventListener("keydown", (e) => this.trapFocus(e));
6925
7059
  this.root.addEventListener("click", (e) => {
6926
7060
  if (!this.toolbarDropped) return;
@@ -7040,6 +7174,8 @@ var NuniWidget = class {
7040
7174
  this.sessionUnsub?.();
7041
7175
  this.ownerDetailUnsub?.();
7042
7176
  this.threadUnsub?.();
7177
+ this.mineUnsub?.();
7178
+ clearTimeout(this.pinReportTimer);
7043
7179
  this.resolveRun++;
7044
7180
  for (const stop of this.scopeWatchers.values()) stop();
7045
7181
  this.scopeWatchers.clear();
@@ -7063,6 +7199,8 @@ var NuniWidget = class {
7063
7199
  this.extraComment = null;
7064
7200
  this.loaded = false;
7065
7201
  this.placements.clear();
7202
+ this.pinReported.clear();
7203
+ clearTimeout(this.pinReportTimer);
7066
7204
  this.card = null;
7067
7205
  this.activeId = null;
7068
7206
  this.pageUnsub = this.api.onPage(key, (comments) => {
@@ -7179,11 +7317,39 @@ var NuniWidget = class {
7179
7317
  this.watchScopes();
7180
7318
  this.renderPins();
7181
7319
  this.layout();
7182
- if (queue.length) whenIdle(slice);
7183
- else if ((onlyStale || run > 1) && this.panelOpen && this.placementSummary() !== this.shownPlacements) this.render();
7320
+ if (queue.length) {
7321
+ whenIdle(slice);
7322
+ return;
7323
+ }
7324
+ this.schedulePinReport();
7325
+ if ((onlyStale || run > 1) && this.panelOpen && this.placementSummary() !== this.shownPlacements) this.render();
7184
7326
  };
7185
7327
  slice();
7186
7328
  }
7329
+ /**
7330
+ * Once the pins have settled for a few seconds, report the ones that
7331
+ * couldn't be found (or were found again), so the owner sees them in the
7332
+ * dashboard. Only changes are sent.
7333
+ */
7334
+ schedulePinReport() {
7335
+ clearTimeout(this.pinReportTimer);
7336
+ this.pinReportTimer = window.setTimeout(() => {
7337
+ if (!this.loaded || document.visibilityState !== "visible") return;
7338
+ const lost = [];
7339
+ const found = [];
7340
+ for (const c of this.comments) {
7341
+ if (c.status !== "open" || c.page.path !== this.pageKey) continue;
7342
+ const placement = this.placements.get(c._id);
7343
+ if (!placement) continue;
7344
+ const isLost = !placement.element;
7345
+ if (isLost === (this.pinReported.get(c._id) ?? Boolean(c.pinLost))) continue;
7346
+ if (lost.length + found.length >= LIMITS.pinReportMax) break;
7347
+ this.pinReported.set(c._id, isLost);
7348
+ (isLost ? lost : found).push(c._id);
7349
+ }
7350
+ if (lost.length || found.length) this.api.reportPins(this.pageKey, lost, found);
7351
+ }, PIN_REPORT_DELAY_MS);
7352
+ }
7187
7353
  /** What the panel and cards show from the placements: found, approximate or lost. */
7188
7354
  placementSummary() {
7189
7355
  return this.comments.map((c) => {
@@ -7341,6 +7507,7 @@ var NuniWidget = class {
7341
7507
  this.placeSelectButton();
7342
7508
  }
7343
7509
  setPicking(on) {
7510
+ if (!on) this.repinId = null;
7344
7511
  if (this.picking === on) return;
7345
7512
  this.picking = on;
7346
7513
  const html = document.documentElement;
@@ -7462,10 +7629,16 @@ var NuniWidget = class {
7462
7629
  }
7463
7630
  openComposer(draft) {
7464
7631
  const { element } = draft;
7632
+ const repinId = this.repinId;
7465
7633
  this.setPicking(false);
7634
+ if (repinId) {
7635
+ this.movePin(repinId, draft);
7636
+ return;
7637
+ }
7466
7638
  if (this.config.capture.dom) try {
7467
7639
  draft.dom = domContext(element);
7468
7640
  } catch {}
7641
+ draft.suggestFrom = suggestableText(draft.anchor, element);
7469
7642
  this.card = {
7470
7643
  kind: "composer",
7471
7644
  draft
@@ -7475,14 +7648,18 @@ var NuniWidget = class {
7475
7648
  this.focusComposer();
7476
7649
  draft.screenshot = this.takeScreenshot(draft);
7477
7650
  }
7651
+ /** An iframe's document can't be rendered from the page. */
7652
+ canScreenshot(draft) {
7653
+ return Boolean(this.runtime.loadScreenshot) && draft.element.ownerDocument === document;
7654
+ }
7478
7655
  /** Capture while the person types, so posting stays instant. */
7479
7656
  async takeScreenshot(draft) {
7480
7657
  const load = this.runtime.loadScreenshot;
7481
- if (!load || draft.element.ownerDocument !== document) return null;
7658
+ if (!load || !this.canScreenshot(draft)) return null;
7482
7659
  const accent = getComputedStyle(this.host).getPropertyValue("--n-accent").trim() || "#d6246e";
7483
7660
  try {
7484
7661
  await new Promise((resolve) => setTimeout(resolve, 60));
7485
- const capture = await load();
7662
+ const tools = await load();
7486
7663
  const { range, anchor, element } = draft;
7487
7664
  const focus = range ? () => range.getBoundingClientRect() : anchor.region ? () => {
7488
7665
  const box = regionBox(anchor, element);
@@ -7493,7 +7670,7 @@ var NuniWidget = class {
7493
7670
  bottom: box.top + box.height
7494
7671
  };
7495
7672
  } : void 0;
7496
- return capture ? await capture(element, {
7673
+ return tools ? await tools.capture(element, {
7497
7674
  exclude: this.host,
7498
7675
  accent,
7499
7676
  focus
@@ -7518,19 +7695,93 @@ var NuniWidget = class {
7518
7695
  if (image) await this.api.uploadScreenshot(id, this.secret, image);
7519
7696
  } catch {}
7520
7697
  }
7698
+ /** One after another, so they keep their order. */
7699
+ async attachImages(id, draft) {
7700
+ const images = draft.images ?? [];
7701
+ revokeImages(draft);
7702
+ try {
7703
+ for (const image of images) await this.api.uploadImage(id, this.secret, image.blob);
7704
+ } catch (error) {
7705
+ this.showToast(error instanceof NuniApiError ? error.message : this.t("imageFailed"));
7706
+ }
7707
+ }
7708
+ /** Whether this comment box can take images (claimed sites only). */
7709
+ canAttach(draft) {
7710
+ return this.config.images && Boolean(this.status?.claimed) && (draft.images?.length ?? 0) < LIMITS.imagesPerComment;
7711
+ }
7712
+ async addImages(draft, files) {
7713
+ draft.error = void 0;
7714
+ for (const file of files) {
7715
+ if ((draft.images?.length ?? 0) >= LIMITS.imagesPerComment) {
7716
+ draft.error = this.t("tooManyImages", { count: LIMITS.imagesPerComment });
7717
+ break;
7718
+ }
7719
+ const blob = await imageFromFile(file);
7720
+ if (!blob) {
7721
+ draft.error = this.t("imageUnreadable");
7722
+ continue;
7723
+ }
7724
+ (draft.images ?? (draft.images = [])).push({
7725
+ blob,
7726
+ url: URL.createObjectURL(blob)
7727
+ });
7728
+ }
7729
+ this.render();
7730
+ this.positionCard();
7731
+ }
7732
+ /** Draw on the screenshot, then attach the result for everyone to see. */
7733
+ async markUpScreenshot(draft) {
7734
+ const load = this.runtime.loadScreenshot;
7735
+ if (!load || !draft.screenshot || draft.preparingMarkup) return;
7736
+ draft.preparingMarkup = true;
7737
+ draft.error = void 0;
7738
+ this.render();
7739
+ const [tools, shot] = await Promise.all([load(), draft.screenshot]);
7740
+ draft.preparingMarkup = false;
7741
+ if (!tools || !shot) {
7742
+ this.setDraftError(draft, this.t("noScreenshot"));
7743
+ return;
7744
+ }
7745
+ this.markingUp = true;
7746
+ try {
7747
+ const marked = await tools.markUp(shot, {
7748
+ container: this.modalLayer,
7749
+ color: MARKUP_COLOR,
7750
+ labels: {
7751
+ title: this.t("markUpTitle"),
7752
+ box: this.t("markUpBox"),
7753
+ arrow: this.t("markUpArrow"),
7754
+ pen: this.t("markUpPen"),
7755
+ undo: this.t("markUpUndo"),
7756
+ cancel: this.t("cancel"),
7757
+ done: this.t("markUpDone"),
7758
+ failed: this.t("markUpFailed")
7759
+ }
7760
+ });
7761
+ if (marked) (draft.images ?? (draft.images = [])).push({
7762
+ blob: marked,
7763
+ url: URL.createObjectURL(marked)
7764
+ });
7765
+ } finally {
7766
+ this.markingUp = false;
7767
+ }
7768
+ this.render();
7769
+ this.positionCard();
7770
+ }
7521
7771
  copyForAgent(comment) {
7522
7772
  const detail = this.ownerDetail?._id === comment._id ? this.ownerDetail : null;
7523
7773
  const replies = this.threadId === comment._id ? this.thread?.replies : void 0;
7524
- copyText(buildCommentPrompt(detail ?? {
7774
+ const prompt = buildCommentPrompt(detail ?? {
7525
7775
  ...comment,
7526
7776
  replies
7527
- }, { includeContext: Boolean(detail) })).then((ok) => this.showToast(ok ? this.t("copied") : this.t("copyFailed")));
7777
+ }, { includeContext: Boolean(detail) });
7778
+ copyText(prompt).then((ok) => this.showToast(ok ? this.t("copied") : this.t("copyFailed")));
7528
7779
  }
7529
7780
  renderHighlight() {
7530
7781
  this.overlayLayer.replaceChildren();
7531
7782
  if (!this.picking) return;
7532
7783
  const mobile = window.matchMedia(MOBILE_QUERY).matches;
7533
- this.overlayLayer.append(h("div", { class: "pick-hint" }, mobile ? this.t("pickHintMobile") : [h("span", {}, this.t("pickHint")), h("span", { class: "pick-keys" }, this.t("pickHintKeys"))]));
7784
+ this.overlayLayer.append(h("div", { class: "pick-hint" }, this.repinId ? this.t("movePinHint") : mobile ? this.t("pickHintMobile") : [h("span", {}, this.t("pickHint")), h("span", { class: "pick-keys" }, this.t("pickHintKeys"))]));
7534
7785
  if (this.dragBox) {
7535
7786
  const box = h("div", { class: "highlight drag-box" });
7536
7787
  Object.assign(box.style, {
@@ -7555,6 +7806,7 @@ var NuniWidget = class {
7555
7806
  this.overlayLayer.append(box);
7556
7807
  }
7557
7808
  onKeyDown(e) {
7809
+ if (this.markingUp) return;
7558
7810
  const inWidget = e.composedPath().includes(this.host);
7559
7811
  if (e.key === "Escape") {
7560
7812
  if (this.confirming) this.answer(false);
@@ -7791,7 +8043,15 @@ var NuniWidget = class {
7791
8043
  const cleanName = name.trim().slice(0, LIMITS.nameMaxLength);
7792
8044
  const cleanBody = body.trim();
7793
8045
  if (!cleanName) return this.setDraftError(draft, this.t("enterName"));
7794
- if (!cleanBody) return this.setDraftError(draft, this.t("writeFirst"));
8046
+ const before = draft.suggesting ? draft.suggestFrom : null;
8047
+ const after = (draft.suggestion ?? before ?? "").trim();
8048
+ if (before != null && (!after || after === before)) return this.setDraftError(draft, this.t("changeTextFirst"));
8049
+ if (after.length > LIMITS.quoteMaxLength) return this.setDraftError(draft, this.t("suggestionTooLong", { count: LIMITS.quoteMaxLength }));
8050
+ const suggestion = before != null ? {
8051
+ before,
8052
+ after
8053
+ } : void 0;
8054
+ if (!cleanBody && !suggestion) return this.setDraftError(draft, this.t("writeFirst"));
7795
8055
  if (cleanBody.length > LIMITS.bodyMaxLength) return this.setDraftError(draft, this.t("commentTooLong", { count: LIMITS.bodyMaxLength }));
7796
8056
  this.name = cleanName;
7797
8057
  write(KEYS.name, cleanName);
@@ -7803,6 +8063,7 @@ var NuniWidget = class {
7803
8063
  try {
7804
8064
  const id = await this.api.createComment({
7805
8065
  body: cleanBody,
8066
+ suggestion,
7806
8067
  authorName: cleanName,
7807
8068
  authorSecret: this.secret,
7808
8069
  page: {
@@ -7819,6 +8080,7 @@ var NuniWidget = class {
7819
8080
  context: this.collectContext(draft)
7820
8081
  });
7821
8082
  if (shot) this.attachScreenshot(id, shot);
8083
+ if (draft.images?.length) this.attachImages(id, draft);
7822
8084
  this.placements.set(id, {
7823
8085
  element: draft.element,
7824
8086
  confidence: "exact",
@@ -7874,6 +8136,41 @@ var NuniWidget = class {
7874
8136
  if (!token) return;
7875
8137
  this.act(c._id, () => this.api.reopen(c._id, token), this.t("reopened"));
7876
8138
  }
8139
+ /** Closed because what it was about is gone, not because it was fixed. */
8140
+ closeAsOutdated(c) {
8141
+ const token = this.ownerToken;
8142
+ if (!token) return;
8143
+ this.act(c._id, () => this.api.resolve(c._id, token, "outdated"), this.t("closedOutdated")).then((ok) => {
8144
+ if (ok && this.card?.kind === "thread" && this.card.id === c._id) this.closeCard();
8145
+ });
8146
+ }
8147
+ /** Pick the element the comment is about now; the pin moves there. */
8148
+ startMovePin(c) {
8149
+ if (!this.ownerToken) return;
8150
+ this.setPicking(true);
8151
+ this.repinId = c._id;
8152
+ this.render();
8153
+ }
8154
+ async movePin(id, draft) {
8155
+ const token = this.ownerToken;
8156
+ if (!token) return;
8157
+ const previous = this.placements.get(id);
8158
+ this.placements.set(id, {
8159
+ element: draft.element,
8160
+ confidence: "exact",
8161
+ range: draft.range
8162
+ });
8163
+ this.pinReported.set(id, false);
8164
+ try {
8165
+ await this.api.repin(id, draft.anchor, token);
8166
+ this.showToast(this.t("pinMoved"));
8167
+ } catch (error) {
8168
+ if (previous) this.placements.set(id, previous);
8169
+ else this.placements.delete(id);
8170
+ this.showToast(error instanceof NuniApiError ? error.message : this.t("failed"));
8171
+ }
8172
+ this.focusComment(id);
8173
+ }
7877
8174
  async deleteComment(c) {
7878
8175
  if (!await this.ask(this.t("confirmDeleteComment"), this.t("delete"))) return;
7879
8176
  const token = this.ownerToken;
@@ -7951,8 +8248,16 @@ var NuniWidget = class {
7951
8248
  }
7952
8249
  });
7953
8250
  }
7954
- saveEdit(c, body) {
7955
- this.act(c._id, () => this.api.editOwn(c._id, this.secret, body), this.t("saved"));
8251
+ saveEdit(c, body, suggestion) {
8252
+ this.act(c._id, () => this.api.editOwn(c._id, this.secret, body, suggestion), this.t("saved"));
8253
+ }
8254
+ /** For the owner: the suggested words, ready to paste into the code. */
8255
+ copyNewTextButton(text) {
8256
+ return h("button", {
8257
+ class: "cmp-link",
8258
+ type: "button",
8259
+ onclick: () => void copyText(text).then((ok) => this.showToast(this.t(ok ? "newTextCopied" : "copyFailed")))
8260
+ }, this.t("copyNewText"));
7956
8261
  }
7957
8262
  showToast(message) {
7958
8263
  this.toast = message;
@@ -7966,6 +8271,10 @@ var NuniWidget = class {
7966
8271
  togglePanel(open = !this.panelOpen) {
7967
8272
  if (open === this.panelOpen) return;
7968
8273
  this.panelOpen = open;
8274
+ if (open && !this.mineUnsub) this.mineUnsub = this.api.onMine(this.secret, (mine) => {
8275
+ this.mine = mine;
8276
+ this.render();
8277
+ });
7969
8278
  if (open) {
7970
8279
  this.keepFocus();
7971
8280
  this.focusNext = ".panel [role=\"tab\"][aria-selected=\"true\"]";
@@ -7981,12 +8290,14 @@ var NuniWidget = class {
7981
8290
  */
7982
8291
  closeCardFromOutside(e) {
7983
8292
  if (!this.card || this.picking || this.confirming || !e.isPrimary) return;
8293
+ if (this.markingUp) return;
7984
8294
  if (onScrollbar(e)) return;
7985
- if ([...this.uiLayer.querySelector("[data-card]")?.querySelectorAll("textarea") ?? []].some((t) => t.value.trim())) return;
8295
+ if ([...this.uiLayer.querySelector("[data-card]")?.querySelectorAll("textarea") ?? []].some((t) => t.value.trim()) || this.card.kind === "composer" && Boolean(this.card.draft.images?.length)) return;
7986
8296
  if (!this.panelOpen) this.returnFocus = null;
7987
8297
  this.closeCard();
7988
8298
  }
7989
8299
  closeCard() {
8300
+ if (this.card?.kind === "composer") revokeImages(this.card.draft);
7990
8301
  const had = this.card !== null;
7991
8302
  this.card = null;
7992
8303
  this.activeId = null;
@@ -8044,7 +8355,7 @@ var NuniWidget = class {
8044
8355
  pin.style.background = colorFor(comment.authorName);
8045
8356
  pin.setAttribute("aria-label", this.t("pinLabel", {
8046
8357
  name: comment.authorName,
8047
- body: comment.body.slice(0, 80)
8358
+ body: (comment.body || comment.suggestion?.after || "").slice(0, 80)
8048
8359
  }));
8049
8360
  if (comment.replyCount) pin.dataset.replies = String(comment.replyCount);
8050
8361
  else delete pin.dataset.replies;
@@ -8203,10 +8514,29 @@ var NuniWidget = class {
8203
8514
  const resolved = this.comments.filter((c) => c.status === "resolved");
8204
8515
  const query = this.search.trim().toLocaleLowerCase();
8205
8516
  const matches = (...texts) => !query || texts.some((t) => t?.toLocaleLowerCase().includes(query));
8206
- const list = (this.tab === "open" ? open : resolved).filter((c) => matches(c.body, c.authorName, c.anchor.quote?.exact));
8517
+ const mine = this.mine ?? [];
8518
+ const list = (this.tab === "open" ? open : this.tab === "resolved" ? resolved : mine).filter((c) => matches(c.body, c.authorName, c.anchor.quote?.exact, c.suggestion?.after, this.tab === "mine" ? c.page.path : void 0));
8207
8519
  const placed = list.filter((c) => this.placements.get(c._id)?.element);
8208
8520
  const lost = list.filter((c) => !this.placements.get(c._id)?.element);
8209
8521
  const otherPages = this.pages.filter((p) => p.path !== this.pageKey && matches(p.path));
8522
+ const content = (c) => [c.suggestion ? renderSuggestionLine(c.suggestion) : c.anchor.quote ? h("div", { class: "item-quote" }, c.anchor.quote.exact) : null, c.body ? h("div", { class: "item-text" }, c.body) : null];
8523
+ const mineItem = (c) => {
8524
+ const here = c.page.path === this.pageKey;
8525
+ const href = here ? null : commentHref(c, location);
8526
+ const resolvedBadge = c.status === "resolved";
8527
+ return h(here ? "button" : href === null ? "div" : "a", here ? {
8528
+ class: "item",
8529
+ type: "button",
8530
+ "data-focus-key": `item-${c._id}`,
8531
+ onclick: () => {
8532
+ this.deepLinkId = c._id;
8533
+ this.openDeepLink();
8534
+ }
8535
+ } : {
8536
+ class: "item",
8537
+ href
8538
+ }, h("div", { class: "row" }, h("span", { class: "author" }, here ? this.t("thisPage") : c.page.path), h("span", { class: "meta" }, this.i18n.timeAgo(c.createdAt)), h("span", { class: "spacer" }), h("span", { class: resolvedBadge ? "badge badge-ok" : "badge" }, resolvedBadge ? icon(ICONS.check) : null, this.t(!resolvedBadge ? "statusOpen" : c.resolution === "outdated" ? "outdated" : "resolved"))), ...content(c));
8539
+ };
8210
8540
  const item = (c) => h("button", {
8211
8541
  class: "item",
8212
8542
  type: "button",
@@ -8219,9 +8549,13 @@ var NuniWidget = class {
8219
8549
  }, initials(c.authorName)), h("span", { class: "author" }, c.authorName), h("span", { class: "meta" }, this.i18n.timeAgo(c.createdAt) + (c.replyCount ? ` · ${this.t("replyCount", { count: c.replyCount })}` : "")), c.page.origin !== location.origin ? h("span", {
8220
8550
  class: "badge",
8221
8551
  title: c.page.origin
8222
- }, new URL(c.page.origin).host) : null), c.anchor.quote ? h("div", { class: "item-quote" }, c.anchor.quote.exact) : null, h("div", { class: "item-text" }, c.body));
8552
+ }, new URL(c.page.origin).host) : null, c.resolution === "outdated" ? h("span", { class: "badge" }, this.t("outdated")) : null), ...content(c));
8223
8553
  const children = [];
8224
- if (!this.loaded) children.push(h("div", { class: "empty" }, this.t("loading")));
8554
+ if (this.tab === "mine") {
8555
+ if (!this.mine) children.push(h("div", { class: "empty" }, this.t("loading")));
8556
+ else if (!list.length) children.push(h("div", { class: "empty" }, query ? this.t("noMatches", { query: this.search.trim() }) : this.t("noYours")));
8557
+ else children.push(...list.map(mineItem));
8558
+ } else if (!this.loaded) children.push(h("div", { class: "empty" }, this.t("loading")));
8225
8559
  else if (!list.length && query) children.push(h("div", { class: "empty" }, this.t("noMatches", { query: this.search.trim() })));
8226
8560
  else if (!list.length) children.push(h("div", { class: "empty" }, this.tab === "open" ? this.withKey("noOpen", "noOpenKey") : this.t("noResolved")));
8227
8561
  else {
@@ -8231,7 +8565,7 @@ var NuniWidget = class {
8231
8565
  children.push(...lost.map(item));
8232
8566
  }
8233
8567
  }
8234
- if (otherPages.length) {
8568
+ if (otherPages.length && this.tab !== "mine") {
8235
8569
  children.push(h("div", { class: "section-label" }, this.t("otherPages")));
8236
8570
  for (const p of otherPages.slice(0, 20)) {
8237
8571
  const href = otherPageHref(p.path, location);
@@ -8250,7 +8584,7 @@ var NuniWidget = class {
8250
8584
  type: "button",
8251
8585
  "aria-label": this.t("close"),
8252
8586
  onclick: () => this.togglePanel(false)
8253
- }, icon(ICONS.close))), this.comments.length ? h("div", { class: "panel-search" }, icon(ICONS.search), h("input", {
8587
+ }, icon(ICONS.close))), this.comments.length || this.mine?.length ? h("div", { class: "panel-search" }, icon(ICONS.search), h("input", {
8254
8588
  class: "field",
8255
8589
  type: "search",
8256
8590
  value: this.search,
@@ -8275,11 +8609,13 @@ var NuniWidget = class {
8275
8609
  "End"
8276
8610
  ].includes(key)) return;
8277
8611
  e.preventDefault();
8278
- this.tab = key === "Home" ? "open" : key === "End" ? "resolved" : this.tab === "open" ? "resolved" : "open";
8612
+ const at = PANEL_TABS.indexOf(this.tab);
8613
+ const last = PANEL_TABS.length - 1;
8614
+ this.tab = PANEL_TABS[key === "Home" ? 0 : key === "End" ? last : key === "ArrowRight" ? (at + 1) % PANEL_TABS.length : (at + last) % PANEL_TABS.length];
8279
8615
  this.focusNext = `[data-focus-key="tab-${this.tab}"]`;
8280
8616
  this.render();
8281
8617
  }
8282
- }, ["open", "resolved"].map((tab) => h("button", {
8618
+ }, PANEL_TABS.map((tab) => h("button", {
8283
8619
  class: "tab",
8284
8620
  role: "tab",
8285
8621
  type: "button",
@@ -8292,7 +8628,7 @@ var NuniWidget = class {
8292
8628
  this.tab = tab;
8293
8629
  this.render();
8294
8630
  }
8295
- }, tab === "open" ? this.t("tabOpen", { count: open.length }) : this.t("tabResolved", { count: resolved.length })))), h("div", {
8631
+ }, tab === "open" ? this.t("tabOpen", { count: open.length }) : tab === "resolved" ? this.t("tabResolved", { count: resolved.length }) : this.mine ? this.t("tabYours", { count: this.mine.length }) : this.t("yours")))), h("div", {
8296
8632
  class: "panel-list",
8297
8633
  id: "nuni-panel-list",
8298
8634
  role: "tabpanel",
@@ -8332,14 +8668,25 @@ var NuniWidget = class {
8332
8668
  "data-focus-key": "name",
8333
8669
  "aria-label": this.t("yourName")
8334
8670
  });
8671
+ const before = draft.suggestFrom ?? null;
8672
+ const suggesting = Boolean(draft.suggesting) && before !== null;
8335
8673
  const textarea = h("textarea", {
8336
8674
  class: "cmp-text",
8337
- placeholder: this.t("leaveComment"),
8675
+ placeholder: this.t(suggesting ? "addNote" : "leaveComment"),
8338
8676
  maxlength: LIMITS.bodyMaxLength,
8339
8677
  rows: 3,
8340
8678
  "data-focus-key": "body",
8341
8679
  "aria-label": this.t("comment")
8342
8680
  });
8681
+ const suggestInput = suggesting ? h("textarea", {
8682
+ class: "cmp-suggest",
8683
+ maxlength: LIMITS.quoteMaxLength,
8684
+ rows: 1,
8685
+ "data-focus-key": "suggestion",
8686
+ "aria-label": this.t("newText")
8687
+ }) : null;
8688
+ if (suggestInput) suggestInput.value = draft.suggestion ?? before ?? "";
8689
+ const canPost = () => Boolean(textarea.value.trim()) || Boolean(suggestInput && suggestInput.value.trim() && suggestInput.value.trim() !== before);
8343
8690
  const prev = this.uiLayer.querySelector("[data-focus-key=\"body\"]");
8344
8691
  if (prev) textarea.value = prev.value;
8345
8692
  const prevName = this.uiLayer.querySelector("[data-focus-key=\"name\"]");
@@ -8353,31 +8700,68 @@ var NuniWidget = class {
8353
8700
  const post = h("button", {
8354
8701
  class: "cmp-post",
8355
8702
  type: "submit",
8356
- disabled: Boolean(draft.sending) || !textarea.value.trim()
8703
+ disabled: Boolean(draft.sending) || !canPost()
8357
8704
  }, draft.sending ? this.t("posting") : this.t("post"), icon(ICONS.arrowUp));
8358
8705
  const update = () => {
8359
- post.disabled = Boolean(draft.sending) || !textarea.value.trim();
8706
+ post.disabled = Boolean(draft.sending) || !canPost();
8360
8707
  const left = LIMITS.bodyMaxLength - textarea.value.length;
8361
8708
  counter.textContent = left <= LIMITS.bodyMaxLength * .1 ? this.t("charsLeft", { count: left }) : "";
8362
- textarea.style.height = "auto";
8363
- textarea.style.height = `${textarea.scrollHeight + 2}px`;
8709
+ for (const field of [textarea, suggestInput]) {
8710
+ if (!field) continue;
8711
+ field.style.height = "auto";
8712
+ field.style.height = `${field.scrollHeight + 2}px`;
8713
+ }
8364
8714
  };
8365
8715
  textarea.addEventListener("input", () => {
8366
8716
  update();
8367
8717
  this.positionCard();
8368
8718
  });
8719
+ suggestInput?.addEventListener("input", () => {
8720
+ draft.suggestion = suggestInput.value;
8721
+ update();
8722
+ this.positionCard();
8723
+ });
8369
8724
  requestAnimationFrame(() => {
8370
8725
  if (!textarea.isConnected) return;
8371
8726
  update();
8372
8727
  this.positionCard();
8373
8728
  });
8374
8729
  const submit = () => void this.submitDraft(draft, textarea.value, needsName ? nameInput.value : this.name);
8375
- textarea.addEventListener("keydown", (e) => {
8730
+ for (const field of [textarea, suggestInput]) field?.addEventListener("keydown", (e) => {
8376
8731
  if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
8377
8732
  e.preventDefault();
8378
8733
  submit();
8379
8734
  }
8380
8735
  });
8736
+ const attach = this.canAttach(draft);
8737
+ const fileInput = attach ? h("input", {
8738
+ type: "file",
8739
+ accept: IMAGE_TYPES.join(","),
8740
+ multiple: true,
8741
+ hidden: true,
8742
+ tabindex: -1,
8743
+ "aria-hidden": "true",
8744
+ onchange: (e) => {
8745
+ const input = e.target;
8746
+ const files = [...input.files ?? []];
8747
+ input.value = "";
8748
+ this.addImages(draft, files);
8749
+ }
8750
+ }) : null;
8751
+ if (attach) textarea.addEventListener("paste", (e) => {
8752
+ const files = [...e.clipboardData?.files ?? []].filter((f) => IMAGE_TYPES.includes(f.type));
8753
+ if (!files.length) return;
8754
+ e.preventDefault();
8755
+ this.addImages(draft, files);
8756
+ });
8757
+ const setSuggesting = (on) => {
8758
+ draft.suggesting = on;
8759
+ draft.error = void 0;
8760
+ this.render();
8761
+ const field = this.uiLayer.querySelector(`[data-focus-key="${on ? "suggestion" : "body"}"]`);
8762
+ field?.focus();
8763
+ if (on) field?.select();
8764
+ };
8381
8765
  return h("form", {
8382
8766
  class: `${cls} composer`,
8383
8767
  "data-card": "composer",
@@ -8393,13 +8777,48 @@ var NuniWidget = class {
8393
8777
  this.render();
8394
8778
  this.focusComposer();
8395
8779
  }
8396
- }, this.t("notYou"))), draft.anchor.quote ? h("blockquote", { class: "quote" }, draft.anchor.quote.exact) : null, textarea, draft.error ? h("div", {
8780
+ }, this.t("notYou"))), suggesting ? h("div", { class: "suggest" }, h("div", { class: "suggest-label" }, this.t("suggestedEdit"), h("span", { class: "spacer" }), h("button", {
8781
+ class: "cmp-link",
8782
+ type: "button",
8783
+ onclick: () => setSuggesting(false)
8784
+ }, this.t("removeSuggestion"))), h("del", {}, before), suggestInput) : draft.anchor.quote ? h("blockquote", { class: "quote" }, draft.anchor.quote.exact) : null, textarea, draft.images?.length ? h("div", { class: "cmp-images" }, h("div", { class: "cmp-thumbs" }, ...draft.images.map((image, i) => h("div", { class: "cmp-thumb" }, h("img", {
8785
+ src: image.url,
8786
+ alt: this.t("imageN", { n: i + 1 })
8787
+ }), h("button", {
8788
+ class: "cmp-thumb-remove",
8789
+ type: "button",
8790
+ "aria-label": this.t("removeImage", { n: i + 1 }),
8791
+ title: this.t("removeImage", { n: i + 1 }),
8792
+ onclick: () => {
8793
+ URL.revokeObjectURL(image.url);
8794
+ draft.images?.splice(i, 1);
8795
+ this.render();
8796
+ this.positionCard();
8797
+ }
8798
+ }, icon(ICONS.close))))), h("p", { class: "cmp-images-note" }, this.t("imagesPublic"))) : null, draft.error ? h("div", {
8397
8799
  class: "error cmp-error",
8398
8800
  role: "alert"
8399
8801
  }, draft.error) : null, h("div", { class: "cmp-foot" }, h("span", {
8400
8802
  class: "cmp-keys",
8401
8803
  title: this.t("send", { key: this.i18n.mod })
8402
- }, h("kbd", {}, this.i18n.mod), h("kbd", {}, "↵")), counter, h("span", { class: "spacer" }), post), h("button", {
8804
+ }, h("kbd", {}, this.i18n.mod), h("kbd", {}, "↵")), before !== null && !suggesting ? h("button", {
8805
+ class: "cmp-link cmp-suggest-btn",
8806
+ type: "button",
8807
+ onclick: () => setSuggesting(true)
8808
+ }, icon(ICONS.edit), this.t("suggestEdit")) : null, attach ? h("button", {
8809
+ class: "icon-btn",
8810
+ type: "button",
8811
+ "aria-label": this.t("addImage"),
8812
+ title: this.t("addImage"),
8813
+ onclick: () => fileInput?.click()
8814
+ }, icon(ICONS.image)) : null, attach && this.canScreenshot(draft) ? h("button", {
8815
+ class: "icon-btn",
8816
+ type: "button",
8817
+ "aria-label": this.t("markUpScreenshot"),
8818
+ title: this.t(draft.preparingMarkup ? "preparingScreenshot" : "markUpScreenshot"),
8819
+ disabled: Boolean(draft.preparingMarkup),
8820
+ onclick: () => void this.markUpScreenshot(draft)
8821
+ }, icon(ICONS.pen)) : null, fileInput, counter, h("span", { class: "spacer" }), post), h("button", {
8403
8822
  class: "icon-btn cmp-close",
8404
8823
  type: "button",
8405
8824
  "aria-label": this.t("cancel"),
@@ -8422,11 +8841,24 @@ var NuniWidget = class {
8422
8841
  const textarea = h("textarea", {
8423
8842
  class: "field",
8424
8843
  maxlength: LIMITS.bodyMaxLength,
8425
- "data-focus-key": "edit"
8844
+ placeholder: comment.suggestion ? this.t("addNote") : null,
8845
+ "data-focus-key": "edit",
8846
+ "aria-label": this.t("comment")
8426
8847
  });
8427
8848
  const prev = this.uiLayer.querySelector("[data-focus-key=\"edit\"]");
8428
8849
  textarea.value = prev ? prev.value : comment.body;
8429
- body = h("div", { class: "card-body" }, textarea, h("div", { class: "row" }, h("span", { class: "spacer" }), h("button", {
8850
+ const suggestField = comment.suggestion ? h("textarea", {
8851
+ class: "field",
8852
+ rows: 2,
8853
+ maxlength: LIMITS.quoteMaxLength,
8854
+ "data-focus-key": "edit-suggestion",
8855
+ "aria-label": this.t("newText")
8856
+ }) : null;
8857
+ if (suggestField && comment.suggestion) {
8858
+ const prevSuggestion = this.uiLayer.querySelector("[data-focus-key=\"edit-suggestion\"]");
8859
+ suggestField.value = prevSuggestion ? prevSuggestion.value : comment.suggestion.after;
8860
+ }
8861
+ body = h("div", { class: "card-body" }, comment.suggestion && suggestField ? h("div", { class: "suggest" }, h("div", { class: "suggest-label" }, this.t("suggestedEdit")), h("del", {}, comment.suggestion.before), suggestField) : null, textarea, h("div", { class: "row" }, h("span", { class: "spacer" }), h("button", {
8430
8862
  class: "btn btn-ghost",
8431
8863
  type: "button",
8432
8864
  onclick: () => {
@@ -8437,11 +8869,11 @@ var NuniWidget = class {
8437
8869
  class: "btn btn-primary",
8438
8870
  type: "button",
8439
8871
  disabled: Boolean(card.busy),
8440
- onclick: () => this.saveEdit(comment, textarea.value)
8872
+ onclick: () => this.saveEdit(comment, textarea.value, suggestField?.value)
8441
8873
  }, this.t("save"))));
8442
8874
  } else {
8443
8875
  const badges = [
8444
- comment.status === "resolved" ? h("span", { class: "badge badge-ok" }, icon(ICONS.check), this.t("resolved")) : null,
8876
+ comment.status === "resolved" ? h("span", { class: "badge badge-ok" }, icon(ICONS.check), this.t(comment.resolution === "outdated" ? "outdated" : "resolved")) : null,
8445
8877
  comment.page.origin !== location.origin ? h("span", {
8446
8878
  class: "badge",
8447
8879
  title: this.t("leftOn", { origin: comment.page.origin })
@@ -8470,7 +8902,16 @@ var NuniWidget = class {
8470
8902
  "aria-label": this.t("close"),
8471
8903
  title: this.t("close"),
8472
8904
  onclick: () => this.closeCard()
8473
- }, icon(ICONS.close))), h("div", { class: "msg-main" }, comment.anchor.quote ? h("blockquote", { class: "quote" }, comment.anchor.quote.exact) : null, h("div", { class: "comment-body" }, comment.body), this.ownerDetail?._id === comment._id && this.ownerDetail.screenshotUrl ? h("a", {
8905
+ }, icon(ICONS.close))), h("div", { class: "msg-main" }, comment.suggestion ? renderSuggestion(comment.suggestion, this.t("suggestedEdit"), owner ? this.copyNewTextButton(comment.suggestion.after) : null) : comment.anchor.quote ? h("blockquote", { class: "quote" }, comment.anchor.quote.exact) : null, comment.body ? h("div", { class: "comment-body" }, comment.body) : null, this.threadId === comment._id && this.thread?.images?.length ? h("div", { class: "images" }, ...this.thread.images.map((url, i) => h("a", {
8906
+ href: url,
8907
+ target: "_blank",
8908
+ rel: "noreferrer",
8909
+ title: this.t("openImage")
8910
+ }, h("img", {
8911
+ src: url,
8912
+ alt: this.t("imageN", { n: i + 1 }),
8913
+ loading: "lazy"
8914
+ })))) : null, this.ownerDetail?._id === comment._id && this.ownerDetail.screenshotUrl ? h("a", {
8474
8915
  class: "shot",
8475
8916
  href: this.ownerDetail.screenshotUrl,
8476
8917
  target: "_blank",
@@ -8479,14 +8920,29 @@ var NuniWidget = class {
8479
8920
  }, h("img", {
8480
8921
  src: this.ownerDetail.screenshotUrl,
8481
8922
  alt: this.t("screenshotTaken")
8482
- })) : null, badges.length ? h("div", { class: "badges" }, ...badges) : null, h("div", { class: "msg-foot" }, this.renderReactions(comment, comment._id), h("span", { class: "spacer" }), h("button", {
8923
+ })) : null, badges.length ? h("div", { class: "badges" }, ...badges) : null, owner && !resolved && !placement?.element ? h("div", { class: "lost" }, h("p", {}, this.t("lostHint")), h("div", { class: "row" }, h("button", {
8924
+ class: "btn",
8925
+ type: "button",
8926
+ onclick: () => this.startMovePin(comment)
8927
+ }, icon(ICONS.target), this.t("movePin")), h("button", {
8928
+ class: "btn btn-ghost",
8929
+ type: "button",
8930
+ disabled: Boolean(card.busy),
8931
+ onclick: () => this.closeAsOutdated(comment)
8932
+ }, this.t("closeOutdated")))) : null, h("div", { class: "msg-foot" }, this.renderReactions(comment, comment._id), h("span", { class: "spacer" }), h("button", {
8483
8933
  class: "icon-btn",
8484
8934
  type: "button",
8485
8935
  "aria-label": this.t("copyForAgent"),
8486
8936
  title: contextPending ? this.t("loadingContext") : this.t("copyForAgentHint"),
8487
8937
  disabled: contextPending,
8488
8938
  onclick: () => this.copyForAgent(comment)
8489
- }, icon(ICONS.bot)), mine ? h("button", {
8939
+ }, icon(ICONS.bot)), owner && placement?.element ? h("button", {
8940
+ class: "icon-btn",
8941
+ type: "button",
8942
+ "aria-label": this.t("movePin"),
8943
+ title: this.t("movePin"),
8944
+ onclick: () => this.startMovePin(comment)
8945
+ }, icon(ICONS.target)) : null, mine ? h("button", {
8490
8946
  class: "icon-btn",
8491
8947
  type: "button",
8492
8948
  "aria-label": this.t("edit"),
@@ -8813,7 +9269,7 @@ function unmount(state) {
8813
9269
  * down the widget it was created for.
8814
9270
  */
8815
9271
  function init(options) {
8816
- return mount(options, { loadScreenshot: () => import("./screenshot-DcJ_SzKF.js").then((m) => m.captureScreenshot) });
9272
+ return mount(options, { loadScreenshot: () => import("./screenshot-tools-Dk-xNV0c.js").then((m) => m.screenshotTools) });
8817
9273
  }
8818
9274
  //#endregion
8819
9275
  export { VERSION, init, EN as messages };