@nuniapp/widget 0.1.7 → 0.1.9

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.7";
179
+ const VERSION = "0.1.9";
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,
@@ -191,8 +203,10 @@ function resolveConfig(options) {
191
203
  }
192
204
  const POSITIONS = [
193
205
  "bottom-right",
206
+ "bottom-center",
194
207
  "bottom-left",
195
208
  "top-right",
209
+ "top-center",
196
210
  "top-left"
197
211
  ];
198
212
  const THEMES = [
@@ -5270,6 +5284,13 @@ var NuniApi = class {
5270
5284
  path
5271
5285
  }, (comments) => cb(comments), onError);
5272
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
+ }
5273
5294
  onPages(cb) {
5274
5295
  return this.client.onUpdate(api.comments.pagesWithComments, { publicId: this.config.project }, cb, () => {});
5275
5296
  }
@@ -5338,9 +5359,16 @@ var NuniApi = class {
5338
5359
  if (!response.ok || !data.id) throw new NuniApiError(data.message ?? "Couldn't post the reply", data.code ?? "error");
5339
5360
  return data.id;
5340
5361
  }
5341
- /** Attach a screenshot to the author's own, just-posted comment. */
5342
- async uploadScreenshot(id, authorSecret, image) {
5343
- 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}`, {
5344
5372
  method: "POST",
5345
5373
  headers: {
5346
5374
  "Content-Type": image.type,
@@ -5352,7 +5380,7 @@ var NuniApi = class {
5352
5380
  });
5353
5381
  if (!response.ok) {
5354
5382
  const data = await response.json().catch(() => ({}));
5355
- 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");
5356
5384
  }
5357
5385
  }
5358
5386
  async run(promise) {
@@ -5362,11 +5390,12 @@ var NuniApi = class {
5362
5390
  throw new NuniApiError(errorMessage(error), "error");
5363
5391
  }
5364
5392
  }
5365
- editOwn(id, authorSecret, body) {
5393
+ editOwn(id, authorSecret, body, suggestion) {
5366
5394
  return this.run(this.client.mutation(api.comments.editOwn, {
5367
5395
  id,
5368
5396
  authorSecret,
5369
- body
5397
+ body,
5398
+ suggestion
5370
5399
  }));
5371
5400
  }
5372
5401
  deleteOwn(id, authorSecret) {
@@ -5375,12 +5404,40 @@ var NuniApi = class {
5375
5404
  authorSecret
5376
5405
  }));
5377
5406
  }
5378
- resolve(id, sessionToken) {
5407
+ resolve(id, sessionToken, resolution) {
5379
5408
  return this.run(this.client.mutation(api.comments.resolve, {
5380
5409
  id,
5410
+ resolution,
5411
+ sessionToken
5412
+ }));
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,
5381
5419
  sessionToken
5382
5420
  }));
5383
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
+ }
5384
5441
  reopen(id, sessionToken) {
5385
5442
  return this.run(this.client.mutation(api.comments.reopen, {
5386
5443
  id,
@@ -5423,106 +5480,6 @@ var NuniApi = class {
5423
5480
  }
5424
5481
  };
5425
5482
  //#endregion
5426
- //#region src/dom.ts
5427
- /** Tiny element factory. Strings become text nodes (never HTML). */
5428
- function h(tag, attrs = {}, ...children) {
5429
- const el = document.createElement(tag);
5430
- for (const [key, value] of Object.entries(attrs)) {
5431
- if (value == null || value === false) continue;
5432
- if (key.startsWith("on") && typeof value === "function") el.addEventListener(key.slice(2).toLowerCase(), value);
5433
- else if (key === "class") el.className = String(value);
5434
- else if (value === true) el.setAttribute(key, "");
5435
- else el.setAttribute(key, String(value));
5436
- }
5437
- for (const child of children.flat()) {
5438
- if (child == null || child === false) continue;
5439
- el.append(typeof child === "string" ? document.createTextNode(child) : child);
5440
- }
5441
- return el;
5442
- }
5443
- /** Renders one of the constant SVG strings in icons.ts (never user input). */
5444
- function icon(svg) {
5445
- const span = document.createElement("span");
5446
- span.className = "icon";
5447
- span.setAttribute("aria-hidden", "true");
5448
- const parsed = new DOMParser().parseFromString(svg, "image/svg+xml");
5449
- span.append(document.importNode(parsed.documentElement, true));
5450
- return span;
5451
- }
5452
- function initials(name) {
5453
- const parts = name.trim().split(/\s+/).filter(Boolean);
5454
- return (parts.length > 1 ? parts[0][0] + parts[parts.length - 1][0] : (parts[0] ?? "?").slice(0, 2)).toUpperCase();
5455
- }
5456
- /** Stable pleasant color per author name. */
5457
- function colorFor(name) {
5458
- let hash = 0;
5459
- for (const ch of name) hash = hash * 31 + ch.charCodeAt(0) >>> 0;
5460
- return `oklch(0.5 0.17 ${hash % 360})`;
5461
- }
5462
- /** Copy text, falling back to execCommand where the async clipboard is off (plain http previews). */
5463
- async function copyText(text) {
5464
- try {
5465
- await navigator.clipboard.writeText(text);
5466
- return true;
5467
- } catch {}
5468
- const area = document.createElement("textarea");
5469
- area.value = text;
5470
- area.setAttribute("readonly", "");
5471
- area.style.cssText = "position:fixed;top:0;left:0;opacity:0";
5472
- document.body.appendChild(area);
5473
- area.select();
5474
- try {
5475
- return document.execCommand("copy");
5476
- } catch {
5477
- return false;
5478
- } finally {
5479
- area.remove();
5480
- }
5481
- }
5482
- /** Any CSS color as RGB, using the browser's own parser. */
5483
- function parseColor(color) {
5484
- const probe = document.createElement("span");
5485
- probe.style.color = color;
5486
- if (!probe.style.color) return null;
5487
- probe.style.display = "none";
5488
- document.documentElement.appendChild(probe);
5489
- const computed = getComputedStyle(probe).color;
5490
- probe.remove();
5491
- if (/^rgba?\(/.test(computed)) {
5492
- const parts = computed.match(/[\d.]+/g)?.map(Number);
5493
- if (parts && parts.length >= 3) return [
5494
- parts[0],
5495
- parts[1],
5496
- parts[2]
5497
- ];
5498
- }
5499
- try {
5500
- const canvas = document.createElement("canvas");
5501
- canvas.width = canvas.height = 1;
5502
- const ctx = canvas.getContext("2d");
5503
- if (!ctx) return null;
5504
- ctx.fillStyle = color;
5505
- ctx.fillRect(0, 0, 1, 1);
5506
- const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
5507
- return [
5508
- r,
5509
- g,
5510
- b
5511
- ];
5512
- } catch {
5513
- return null;
5514
- }
5515
- }
5516
- /** Black or white text, whichever reads better on the color (WCAG). */
5517
- function readableOn([r, g, b]) {
5518
- const channel = (c) => {
5519
- const v = c / 255;
5520
- return v <= .03928 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4;
5521
- };
5522
- const l = .2126 * channel(r) + .7152 * channel(g) + .0722 * channel(b);
5523
- return 1.05 / (l + .05) >= (l + .05) / .05 ? "#ffffff" : "#111111";
5524
- }
5525
- //#endregion
5526
5483
  //#region src/i18n.ts
5527
5484
  /**
5528
5485
  * Every word the widget shows. Override any of them with the `messages`
@@ -5550,11 +5507,18 @@ const EN = {
5550
5507
  close: "Close",
5551
5508
  tabOpen: "Open ({count})",
5552
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",
5553
5515
  loading: "Loading…",
5554
5516
  noOpen: "No open comments on this page.",
5555
5517
  noOpenKey: "No open comments on this page. Press {key} to add one.",
5556
5518
  noResolved: "Nothing resolved yet.",
5557
5519
  notFoundHere: "Couldn't find on this page",
5520
+ searchComments: "Search comments",
5521
+ noMatches: "No comments match “{query}”.",
5558
5522
  otherPages: "Other pages",
5559
5523
  openOnPage: "{count} open",
5560
5524
  replyCount: {
@@ -5576,6 +5540,25 @@ const EN = {
5576
5540
  one: "{count} character left",
5577
5541
  other: "{count} characters left"
5578
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",
5579
5562
  pinLabel: "Comment by {name}: {body}",
5580
5563
  commentBy: "Comment by {name}",
5581
5564
  openScreenshot: "Open the screenshot",
@@ -5589,6 +5572,15 @@ const EN = {
5589
5572
  resolve: "Resolve",
5590
5573
  reopen: "Reopen",
5591
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",
5592
5584
  copyForAgentHint: "Copy for your coding agent",
5593
5585
  loadingContext: "Loading page context…",
5594
5586
  edit: "Edit",
@@ -5615,6 +5607,12 @@ const EN = {
5615
5607
  linkGone: "That comment was deleted or is on another page",
5616
5608
  enterName: "Enter your name",
5617
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",
5618
5616
  commentTooLong: "Comments are limited to {count} characters",
5619
5617
  replyTooLong: "Replies are limited to {count} characters",
5620
5618
  postFailed: "Couldn't post the comment",
@@ -5690,7 +5688,10 @@ const ICONS = {
5690
5688
  smile: s("<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M8 14s1.5 2 4 2 4-2 4-2M9 9h.01M15 9h.01\"/>"),
5691
5689
  user: s("<circle cx=\"12\" cy=\"8\" r=\"4\"/><path d=\"M4 21a8 8 0 0 1 16 0\"/>"),
5692
5690
  arrowUp: s("<path d=\"M12 19V5M5 12l7-7 7 7\"/>"),
5691
+ search: s("<circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m21 21-4.3-4.3\"/>"),
5693
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\"/>"),
5694
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>`,
5695
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\"/>")
5696
5697
  };
@@ -5945,7 +5946,11 @@ function write(key, value) {
5945
5946
  const KEYS = {
5946
5947
  name: "nuni:name",
5947
5948
  secret: "nuni:author-secret",
5948
- session: (project) => `nuni:session:${project}`
5949
+ session: (project) => `nuni:session:${project}`,
5950
+ /** Where this visitor dragged the toolbar. */
5951
+ position: (project) => `nuni:position:${project}`,
5952
+ /** The comment this visitor opened last, marked in the panel. */
5953
+ lastViewed: (project) => `nuni:last-viewed:${project}`
5949
5954
  };
5950
5955
  //#endregion
5951
5956
  //#region src/styles.ts
@@ -6022,7 +6027,18 @@ button { cursor: pointer; }
6022
6027
  border: 1px solid var(--n-border);
6023
6028
  box-shadow: var(--n-shadow);
6024
6029
  pointer-events: auto;
6025
- }
6030
+ cursor: grab;
6031
+ touch-action: none;
6032
+ user-select: none;
6033
+ -webkit-user-select: none;
6034
+ }
6035
+ .toolbar[data-dragging] {
6036
+ cursor: grabbing;
6037
+ box-shadow: 0 2px 4px rgba(0,0,0,0.08), 0 16px 40px rgba(0,0,0,0.2);
6038
+ }
6039
+ /* While dragging, the host covers the page so no move is lost to it. */
6040
+ :host([data-dragging]) { pointer-events: auto; cursor: grabbing; }
6041
+ :host([data-dragging]) .tb-btn { pointer-events: none; }
6026
6042
  .tb-btn {
6027
6043
  display: inline-flex;
6028
6044
  align-items: center;
@@ -6179,7 +6195,6 @@ button { cursor: pointer; }
6179
6195
  gap: 12px;
6180
6196
  }
6181
6197
  .confirm p { margin: 0; }
6182
- .btn-danger-solid { background: var(--n-danger); color: #fff; border-color: transparent; }
6183
6198
 
6184
6199
  /* Pins */
6185
6200
  .pin {
@@ -6454,6 +6469,10 @@ textarea.field { resize: vertical; min-height: 72px; max-height: 240px; }
6454
6469
  .btn-ghost { border-color: transparent; background: transparent; }
6455
6470
  .btn-icon { width: 32px; padding: 0; justify-content: center; }
6456
6471
  .btn-danger { color: var(--n-danger); }
6472
+ /* After .btn, which would otherwise paint over the red with the card's background. */
6473
+ .btn-danger-solid,
6474
+ .btn-danger-solid:hover { background: var(--n-danger); border-color: var(--n-danger); color: #fff; }
6475
+ .btn-danger-solid:hover { filter: brightness(1.08); }
6457
6476
  .error { color: var(--n-danger); font-size: 12px; }
6458
6477
  .divider { height: 1px; background: var(--n-border); }
6459
6478
 
@@ -6490,7 +6509,90 @@ textarea.field { resize: vertical; min-height: 72px; max-height: 240px; }
6490
6509
  white-space: nowrap;
6491
6510
  }
6492
6511
  .cmp-link:hover { color: var(--n-fg); }
6493
- .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; }
6494
6596
  .cmp-text {
6495
6597
  display: block;
6496
6598
  width: 100%;
@@ -6559,7 +6661,7 @@ textarea.field { resize: vertical; min-height: 72px; max-height: 240px; }
6559
6661
  }
6560
6662
  .panel-head { display: flex; align-items: center; gap: 8px; padding: 12px; border-bottom: 1px solid var(--n-border); }
6561
6663
  .panel-title { font-weight: 650; flex: 1; }
6562
- .tabs { display: flex; gap: 4px; padding: 8px 12px 0; }
6664
+ .tabs { display: flex; gap: 4px; padding: 0 12px; margin-top: 8px; }
6563
6665
  .tab {
6564
6666
  border: 0;
6565
6667
  background: transparent;
@@ -6582,6 +6684,11 @@ textarea.field { resize: vertical; min-height: 72px; max-height: 240px; }
6582
6684
  border-radius: 10px;
6583
6685
  }
6584
6686
  .item:hover { background: var(--n-bg-subtle); }
6687
+ /* The comment opened last, so it's easy to find your place again. */
6688
+ .item[aria-current="true"] { background: var(--n-accent-soft); box-shadow: inset 3px 0 0 var(--n-accent); }
6689
+ .panel-search { position: relative; padding: 0 12px; margin-top: 8px; }
6690
+ .panel-search .icon { position: absolute; left: 22px; top: 50%; transform: translateY(-50%); color: var(--n-fg-muted); pointer-events: none; }
6691
+ .panel-search .field { padding-left: 32px; }
6585
6692
  .item-text { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; word-break: break-word; }
6586
6693
  .section-label { padding: 12px 10px 4px; font-size: 11px; font-weight: 650; letter-spacing: 0.04em; text-transform: uppercase; color: var(--n-fg-muted); }
6587
6694
  .empty { padding: 32px 16px; text-align: center; color: var(--n-fg-muted); }
@@ -6625,8 +6732,10 @@ a.item { color: inherit; text-decoration: none; }
6625
6732
  * { transition: none !important; animation: none !important; }
6626
6733
  }
6627
6734
 
6628
- /* The toolbar's corner (the position option) */
6735
+ /* The toolbar's spot (the position option, or where it was dragged) */
6629
6736
  :host([data-position$="left"]) .toolbar { right: auto; left: 16px; }
6737
+ :host([data-position$="center"]) .toolbar { left: 0; right: 0; width: max-content; margin-inline: auto; }
6738
+ :host([data-position="top-center"]) .pick-hint { top: auto; bottom: 12px; }
6630
6739
  :host([data-position^="top"]) .toolbar { bottom: auto; top: 16px; }
6631
6740
  :host([data-position$="left"]) .panel { right: auto; left: 12px; }
6632
6741
  :host([data-position^="top"]) .panel { top: 72px; bottom: 12px; }
@@ -6637,7 +6746,35 @@ a.item { color: inherit; text-decoration: none; }
6637
6746
  }
6638
6747
  `;
6639
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
6640
6772
  //#region src/widget.ts
6773
+ const PANEL_TABS = [
6774
+ "open",
6775
+ "resolved",
6776
+ "mine"
6777
+ ];
6641
6778
  const PICKING_CLASS = "nuni-picking";
6642
6779
  const PICK_EVENTS = [
6643
6780
  "pointerdown",
@@ -6680,6 +6817,14 @@ function frameDocuments(doc, depth = 0) {
6680
6817
  return out;
6681
6818
  }
6682
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
+ }
6683
6828
  function isTypingTarget(target) {
6684
6829
  if (!target || target.nodeType !== 1) return false;
6685
6830
  const el = target;
@@ -6699,6 +6844,17 @@ function describeElement(el) {
6699
6844
  return `${tag}${id}${cls}${text ? ` "${text}"` : ""}`;
6700
6845
  }
6701
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
+ /**
6702
6858
  * Build an "Other pages" link target, or null when it would leave the
6703
6859
  * current origin. The stored path comes from anonymous comment submissions;
6704
6860
  * one that does not start with "/" (".evil.com/x", "@evil.com/") would
@@ -6724,6 +6880,8 @@ var NuniWidget = class {
6724
6880
  _defineProperty(this, "overlayLayer", void 0);
6725
6881
  _defineProperty(this, "selectButton", void 0);
6726
6882
  _defineProperty(this, "uiLayer", void 0);
6883
+ _defineProperty(this, "modalLayer", void 0);
6884
+ _defineProperty(this, "markingUp", false);
6727
6885
  _defineProperty(this, "api", void 0);
6728
6886
  _defineProperty(this, "cleanups", []);
6729
6887
  _defineProperty(this, "pageUnsub", null);
@@ -6738,6 +6896,13 @@ var NuniWidget = class {
6738
6896
  _defineProperty(this, "picking", false);
6739
6897
  _defineProperty(this, "panelOpen", false);
6740
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);
6904
+ _defineProperty(this, "search", "");
6905
+ _defineProperty(this, "lastViewedId", void 0);
6741
6906
  _defineProperty(this, "card", null);
6742
6907
  _defineProperty(this, "activeId", null);
6743
6908
  _defineProperty(this, "hoverEl", null);
@@ -6775,6 +6940,10 @@ var NuniWidget = class {
6775
6940
  _defineProperty(this, "resolveTimer", 0);
6776
6941
  _defineProperty(this, "shownPlacements", "");
6777
6942
  _defineProperty(this, "i18n", void 0);
6943
+ _defineProperty(this, "position", void 0);
6944
+ _defineProperty(this, "toolbarDrag", null);
6945
+ _defineProperty(this, "endToolbarDrag", null);
6946
+ _defineProperty(this, "toolbarDropped", false);
6778
6947
  _defineProperty(this, "isIgnored", (el) => el === this.host || this.host.contains(el));
6779
6948
  _defineProperty(this, "onFrameKey", (e) => this.onKeyDown(e));
6780
6949
  _defineProperty(this, "swallow", (e) => {
@@ -6854,6 +7023,8 @@ var NuniWidget = class {
6854
7023
  }
6855
7024
  this.secret = secret;
6856
7025
  this.ownerToken = read(KEYS.session(config.project));
7026
+ this.lastViewedId = read(KEYS.lastViewed(config.project));
7027
+ this.position = oneOf(read(KEYS.position(config.project)) ?? void 0, POSITIONS, config.position);
6857
7028
  this.host = document.createElement("div");
6858
7029
  this.host.id = "nuni-root";
6859
7030
  this.host.setAttribute("data-nuni", "");
@@ -6868,6 +7039,7 @@ var NuniWidget = class {
6868
7039
  this.pinLayer = h("div", { class: "pins" });
6869
7040
  this.overlayLayer = h("div", { class: "overlay" });
6870
7041
  this.uiLayer = h("div", { class: "ui" });
7042
+ this.modalLayer = h("div", { class: "modal" });
6871
7043
  this.selectButton = h("button", {
6872
7044
  class: "select-btn",
6873
7045
  type: "button",
@@ -6882,8 +7054,13 @@ var NuniWidget = class {
6882
7054
  role: "status",
6883
7055
  "aria-live": "polite"
6884
7056
  });
6885
- 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);
6886
7058
  this.uiLayer.addEventListener("keydown", (e) => this.trapFocus(e));
7059
+ this.root.addEventListener("click", (e) => {
7060
+ if (!this.toolbarDropped) return;
7061
+ e.preventDefault();
7062
+ e.stopPropagation();
7063
+ }, true);
6887
7064
  this.api = new NuniApi(config);
6888
7065
  }
6889
7066
  t(key, vars) {
@@ -6896,8 +7073,8 @@ var NuniWidget = class {
6896
7073
  }
6897
7074
  /** Position, theme, brand color and stacking order from the options. */
6898
7075
  applyAppearance() {
6899
- const { position, theme, accentColor, zIndex } = this.config;
6900
- this.host.dataset.position = position;
7076
+ const { theme, accentColor, zIndex } = this.config;
7077
+ this.host.dataset.position = this.position;
6901
7078
  this.host.dataset.theme = theme;
6902
7079
  if (zIndex !== null) this.host.style.zIndex = String(zIndex);
6903
7080
  const rgb = accentColor ? parseColor(accentColor) : null;
@@ -6944,6 +7121,7 @@ var NuniWidget = class {
6944
7121
  if (e.composedPath().includes(this.host)) return;
6945
7122
  this.pointerDown = true;
6946
7123
  this.scheduleSelection();
7124
+ this.closeCardFromOutside(e);
6947
7125
  };
6948
7126
  const onUp = () => {
6949
7127
  this.pointerDown = false;
@@ -6991,10 +7169,13 @@ var NuniWidget = class {
6991
7169
  }
6992
7170
  destroy() {
6993
7171
  this.setPicking(false);
7172
+ this.endToolbarDrag?.();
6994
7173
  this.pageUnsub?.();
6995
7174
  this.sessionUnsub?.();
6996
7175
  this.ownerDetailUnsub?.();
6997
7176
  this.threadUnsub?.();
7177
+ this.mineUnsub?.();
7178
+ clearTimeout(this.pinReportTimer);
6998
7179
  this.resolveRun++;
6999
7180
  for (const stop of this.scopeWatchers.values()) stop();
7000
7181
  this.scopeWatchers.clear();
@@ -7018,6 +7199,8 @@ var NuniWidget = class {
7018
7199
  this.extraComment = null;
7019
7200
  this.loaded = false;
7020
7201
  this.placements.clear();
7202
+ this.pinReported.clear();
7203
+ clearTimeout(this.pinReportTimer);
7021
7204
  this.card = null;
7022
7205
  this.activeId = null;
7023
7206
  this.pageUnsub = this.api.onPage(key, (comments) => {
@@ -7134,11 +7317,39 @@ var NuniWidget = class {
7134
7317
  this.watchScopes();
7135
7318
  this.renderPins();
7136
7319
  this.layout();
7137
- if (queue.length) whenIdle(slice);
7138
- 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();
7139
7326
  };
7140
7327
  slice();
7141
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
+ }
7142
7353
  /** What the panel and cards show from the placements: found, approximate or lost. */
7143
7354
  placementSummary() {
7144
7355
  return this.comments.map((c) => {
@@ -7296,6 +7507,7 @@ var NuniWidget = class {
7296
7507
  this.placeSelectButton();
7297
7508
  }
7298
7509
  setPicking(on) {
7510
+ if (!on) this.repinId = null;
7299
7511
  if (this.picking === on) return;
7300
7512
  this.picking = on;
7301
7513
  const html = document.documentElement;
@@ -7417,10 +7629,16 @@ var NuniWidget = class {
7417
7629
  }
7418
7630
  openComposer(draft) {
7419
7631
  const { element } = draft;
7632
+ const repinId = this.repinId;
7420
7633
  this.setPicking(false);
7634
+ if (repinId) {
7635
+ this.movePin(repinId, draft);
7636
+ return;
7637
+ }
7421
7638
  if (this.config.capture.dom) try {
7422
7639
  draft.dom = domContext(element);
7423
7640
  } catch {}
7641
+ draft.suggestFrom = suggestableText(draft.anchor, element);
7424
7642
  this.card = {
7425
7643
  kind: "composer",
7426
7644
  draft
@@ -7430,14 +7648,18 @@ var NuniWidget = class {
7430
7648
  this.focusComposer();
7431
7649
  draft.screenshot = this.takeScreenshot(draft);
7432
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
+ }
7433
7655
  /** Capture while the person types, so posting stays instant. */
7434
7656
  async takeScreenshot(draft) {
7435
7657
  const load = this.runtime.loadScreenshot;
7436
- if (!load || draft.element.ownerDocument !== document) return null;
7658
+ if (!load || !this.canScreenshot(draft)) return null;
7437
7659
  const accent = getComputedStyle(this.host).getPropertyValue("--n-accent").trim() || "#d6246e";
7438
7660
  try {
7439
7661
  await new Promise((resolve) => setTimeout(resolve, 60));
7440
- const capture = await load();
7662
+ const tools = await load();
7441
7663
  const { range, anchor, element } = draft;
7442
7664
  const focus = range ? () => range.getBoundingClientRect() : anchor.region ? () => {
7443
7665
  const box = regionBox(anchor, element);
@@ -7448,7 +7670,7 @@ var NuniWidget = class {
7448
7670
  bottom: box.top + box.height
7449
7671
  };
7450
7672
  } : void 0;
7451
- return capture ? await capture(element, {
7673
+ return tools ? await tools.capture(element, {
7452
7674
  exclude: this.host,
7453
7675
  accent,
7454
7676
  focus
@@ -7473,19 +7695,93 @@ var NuniWidget = class {
7473
7695
  if (image) await this.api.uploadScreenshot(id, this.secret, image);
7474
7696
  } catch {}
7475
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
+ }
7476
7771
  copyForAgent(comment) {
7477
7772
  const detail = this.ownerDetail?._id === comment._id ? this.ownerDetail : null;
7478
7773
  const replies = this.threadId === comment._id ? this.thread?.replies : void 0;
7479
- copyText(buildCommentPrompt(detail ?? {
7774
+ const prompt = buildCommentPrompt(detail ?? {
7480
7775
  ...comment,
7481
7776
  replies
7482
- }, { 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")));
7483
7779
  }
7484
7780
  renderHighlight() {
7485
7781
  this.overlayLayer.replaceChildren();
7486
7782
  if (!this.picking) return;
7487
7783
  const mobile = window.matchMedia(MOBILE_QUERY).matches;
7488
- 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"))]));
7489
7785
  if (this.dragBox) {
7490
7786
  const box = h("div", { class: "highlight drag-box" });
7491
7787
  Object.assign(box.style, {
@@ -7510,12 +7806,16 @@ var NuniWidget = class {
7510
7806
  this.overlayLayer.append(box);
7511
7807
  }
7512
7808
  onKeyDown(e) {
7809
+ if (this.markingUp) return;
7513
7810
  const inWidget = e.composedPath().includes(this.host);
7514
7811
  if (e.key === "Escape") {
7515
7812
  if (this.confirming) this.answer(false);
7516
7813
  else if (this.picking) this.setPicking(false);
7517
7814
  else if (this.card) this.closeCard();
7518
- else if (this.panelOpen) this.togglePanel(false);
7815
+ else if (this.panelOpen && this.search) {
7816
+ this.search = "";
7817
+ this.render();
7818
+ } else if (this.panelOpen) this.togglePanel(false);
7519
7819
  else return;
7520
7820
  e.stopPropagation();
7521
7821
  return;
@@ -7743,7 +8043,15 @@ var NuniWidget = class {
7743
8043
  const cleanName = name.trim().slice(0, LIMITS.nameMaxLength);
7744
8044
  const cleanBody = body.trim();
7745
8045
  if (!cleanName) return this.setDraftError(draft, this.t("enterName"));
7746
- 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"));
7747
8055
  if (cleanBody.length > LIMITS.bodyMaxLength) return this.setDraftError(draft, this.t("commentTooLong", { count: LIMITS.bodyMaxLength }));
7748
8056
  this.name = cleanName;
7749
8057
  write(KEYS.name, cleanName);
@@ -7755,6 +8063,7 @@ var NuniWidget = class {
7755
8063
  try {
7756
8064
  const id = await this.api.createComment({
7757
8065
  body: cleanBody,
8066
+ suggestion,
7758
8067
  authorName: cleanName,
7759
8068
  authorSecret: this.secret,
7760
8069
  page: {
@@ -7771,6 +8080,7 @@ var NuniWidget = class {
7771
8080
  context: this.collectContext(draft)
7772
8081
  });
7773
8082
  if (shot) this.attachScreenshot(id, shot);
8083
+ if (draft.images?.length) this.attachImages(id, draft);
7774
8084
  this.placements.set(id, {
7775
8085
  element: draft.element,
7776
8086
  confidence: "exact",
@@ -7826,6 +8136,41 @@ var NuniWidget = class {
7826
8136
  if (!token) return;
7827
8137
  this.act(c._id, () => this.api.reopen(c._id, token), this.t("reopened"));
7828
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
+ }
7829
8174
  async deleteComment(c) {
7830
8175
  if (!await this.ask(this.t("confirmDeleteComment"), this.t("delete"))) return;
7831
8176
  const token = this.ownerToken;
@@ -7903,8 +8248,16 @@ var NuniWidget = class {
7903
8248
  }
7904
8249
  });
7905
8250
  }
7906
- saveEdit(c, body) {
7907
- 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"));
7908
8261
  }
7909
8262
  showToast(message) {
7910
8263
  this.toast = message;
@@ -7918,15 +8271,33 @@ var NuniWidget = class {
7918
8271
  togglePanel(open = !this.panelOpen) {
7919
8272
  if (open === this.panelOpen) return;
7920
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
+ });
7921
8278
  if (open) {
7922
8279
  this.keepFocus();
7923
8280
  this.focusNext = ".panel [role=\"tab\"][aria-selected=\"true\"]";
7924
8281
  if (window.matchMedia(MOBILE_QUERY).matches) this.card = null;
7925
- }
8282
+ } else this.search = "";
7926
8283
  this.render();
8284
+ if (open) this.uiLayer.querySelector(".panel .item[aria-current=\"true\"]")?.scrollIntoView({ block: "nearest" });
7927
8285
  if (!open && !this.card) this.restoreFocus();
7928
8286
  }
8287
+ /**
8288
+ * A press on the page closes the open card, like Figma, unless the card
8289
+ * holds words that haven't been sent yet.
8290
+ */
8291
+ closeCardFromOutside(e) {
8292
+ if (!this.card || this.picking || this.confirming || !e.isPrimary) return;
8293
+ if (this.markingUp) return;
8294
+ if (onScrollbar(e)) 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;
8296
+ if (!this.panelOpen) this.returnFocus = null;
8297
+ this.closeCard();
8298
+ }
7929
8299
  closeCard() {
8300
+ if (this.card?.kind === "composer") revokeImages(this.card.draft);
7930
8301
  const had = this.card !== null;
7931
8302
  this.card = null;
7932
8303
  this.activeId = null;
@@ -7937,6 +8308,8 @@ var NuniWidget = class {
7937
8308
  openThread(id) {
7938
8309
  this.keepFocus();
7939
8310
  this.activeId = id;
8311
+ this.lastViewedId = id;
8312
+ write(KEYS.lastViewed(this.config.project), id);
7940
8313
  this.card = {
7941
8314
  kind: "thread",
7942
8315
  id
@@ -7982,7 +8355,7 @@ var NuniWidget = class {
7982
8355
  pin.style.background = colorFor(comment.authorName);
7983
8356
  pin.setAttribute("aria-label", this.t("pinLabel", {
7984
8357
  name: comment.authorName,
7985
- body: comment.body.slice(0, 80)
8358
+ body: (comment.body || comment.suggestion?.after || "").slice(0, 80)
7986
8359
  }));
7987
8360
  if (comment.replyCount) pin.dataset.replies = String(comment.replyCount);
7988
8361
  else delete pin.dataset.replies;
@@ -8035,10 +8408,14 @@ var NuniWidget = class {
8035
8408
  }
8036
8409
  renderToolbar() {
8037
8410
  const open = this.comments.filter((c) => c.status === "open").length;
8411
+ const drag = this.toolbarDrag?.moving ? this.toolbarDrag : null;
8038
8412
  return h("div", {
8039
8413
  class: "toolbar",
8040
8414
  role: "toolbar",
8041
- "aria-label": this.t("toolbar")
8415
+ "aria-label": this.t("toolbar"),
8416
+ "data-dragging": !!drag,
8417
+ style: drag && `transform: translate(${drag.dx}px, ${drag.dy}px)`,
8418
+ onpointerdown: (e) => this.startToolbarDrag(e)
8042
8419
  }, h("button", {
8043
8420
  class: "tb-btn",
8044
8421
  type: "button",
@@ -8057,16 +8434,113 @@ var NuniWidget = class {
8057
8434
  onclick: () => this.togglePanel()
8058
8435
  }, icon(ICONS.list), h("span", { class: "tb-count" }, String(open))));
8059
8436
  }
8437
+ /**
8438
+ * Drag the toolbar anywhere; on release it glides to the nearest corner or
8439
+ * edge middle, which is remembered for this visitor.
8440
+ */
8441
+ startToolbarDrag(e) {
8442
+ if (e.button !== 0 || !e.isPrimary) return;
8443
+ this.endToolbarDrag?.();
8444
+ const drag = {
8445
+ id: e.pointerId,
8446
+ x: e.clientX,
8447
+ y: e.clientY,
8448
+ dx: 0,
8449
+ dy: 0,
8450
+ moving: false
8451
+ };
8452
+ this.toolbarDrag = drag;
8453
+ const toolbar = () => this.uiLayer.querySelector(".toolbar");
8454
+ const onMove = (ev) => {
8455
+ if (ev.pointerId !== drag.id) return;
8456
+ drag.dx = ev.clientX - drag.x;
8457
+ drag.dy = ev.clientY - drag.y;
8458
+ if (!drag.moving && Math.hypot(drag.dx, drag.dy) < 5) return;
8459
+ if (!drag.moving) {
8460
+ drag.moving = true;
8461
+ this.host.dataset.dragging = "";
8462
+ toolbar()?.setAttribute("data-dragging", "");
8463
+ }
8464
+ ev.preventDefault();
8465
+ toolbar()?.style.setProperty("transform", `translate(${drag.dx}px, ${drag.dy}px)`);
8466
+ };
8467
+ const onUp = (ev) => {
8468
+ if (ev.pointerId !== drag.id) return;
8469
+ this.endToolbarDrag?.();
8470
+ if (drag.moving) this.dropToolbar(toolbar(), true);
8471
+ };
8472
+ const onCancel = (ev) => {
8473
+ if (ev instanceof PointerEvent && ev.pointerId !== drag.id) return;
8474
+ this.endToolbarDrag?.();
8475
+ if (drag.moving) this.dropToolbar(toolbar(), false);
8476
+ };
8477
+ window.addEventListener("pointermove", onMove, true);
8478
+ window.addEventListener("pointerup", onUp, true);
8479
+ window.addEventListener("pointercancel", onCancel, true);
8480
+ window.addEventListener("blur", onCancel);
8481
+ this.endToolbarDrag = () => {
8482
+ window.removeEventListener("pointermove", onMove, true);
8483
+ window.removeEventListener("pointerup", onUp, true);
8484
+ window.removeEventListener("pointercancel", onCancel, true);
8485
+ window.removeEventListener("blur", onCancel);
8486
+ delete this.host.dataset.dragging;
8487
+ this.toolbarDrag = null;
8488
+ this.endToolbarDrag = null;
8489
+ };
8490
+ }
8491
+ /** Glide the toolbar to the spot nearest where it was let go, or back. */
8492
+ dropToolbar(el, snap) {
8493
+ this.toolbarDropped = true;
8494
+ setTimeout(() => this.toolbarDropped = false);
8495
+ if (!el) return;
8496
+ const from = el.getBoundingClientRect();
8497
+ if (snap) {
8498
+ const position = nearestPosition(from.left + from.width / 2, from.top + from.height / 2);
8499
+ this.position = position;
8500
+ this.host.dataset.position = position;
8501
+ write(KEYS.position(this.config.project), position === this.config.position ? null : position);
8502
+ }
8503
+ el.removeAttribute("data-dragging");
8504
+ el.style.removeProperty("transform");
8505
+ const to = el.getBoundingClientRect();
8506
+ if (matchMedia("(prefers-reduced-motion: reduce)").matches) return;
8507
+ el.animate([{ transform: `translate(${from.left - to.left}px, ${from.top - to.top}px)` }, { transform: "none" }], {
8508
+ duration: 320,
8509
+ easing: "cubic-bezier(0.2, 0.9, 0.3, 1.15)"
8510
+ });
8511
+ }
8060
8512
  renderPanel() {
8061
8513
  const open = this.comments.filter((c) => c.status === "open");
8062
8514
  const resolved = this.comments.filter((c) => c.status === "resolved");
8063
- const list = this.tab === "open" ? open : resolved;
8515
+ const query = this.search.trim().toLocaleLowerCase();
8516
+ const matches = (...texts) => !query || texts.some((t) => t?.toLocaleLowerCase().includes(query));
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));
8064
8519
  const placed = list.filter((c) => this.placements.get(c._id)?.element);
8065
8520
  const lost = list.filter((c) => !this.placements.get(c._id)?.element);
8066
- const otherPages = this.pages.filter((p) => p.path !== this.pageKey);
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
+ };
8067
8540
  const item = (c) => h("button", {
8068
8541
  class: "item",
8069
8542
  type: "button",
8543
+ "aria-current": c._id === this.lastViewedId ? "true" : null,
8070
8544
  "data-focus-key": `item-${c._id}`,
8071
8545
  onclick: () => this.focusComment(c._id)
8072
8546
  }, h("div", { class: "row" }, h("span", {
@@ -8075,9 +8549,14 @@ var NuniWidget = class {
8075
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", {
8076
8550
  class: "badge",
8077
8551
  title: c.page.origin
8078
- }, 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));
8079
8553
  const children = [];
8080
- 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")));
8559
+ else if (!list.length && query) children.push(h("div", { class: "empty" }, this.t("noMatches", { query: this.search.trim() })));
8081
8560
  else if (!list.length) children.push(h("div", { class: "empty" }, this.tab === "open" ? this.withKey("noOpen", "noOpenKey") : this.t("noResolved")));
8082
8561
  else {
8083
8562
  children.push(...placed.map(item));
@@ -8086,7 +8565,7 @@ var NuniWidget = class {
8086
8565
  children.push(...lost.map(item));
8087
8566
  }
8088
8567
  }
8089
- if (otherPages.length) {
8568
+ if (otherPages.length && this.tab !== "mine") {
8090
8569
  children.push(h("div", { class: "section-label" }, this.t("otherPages")));
8091
8570
  for (const p of otherPages.slice(0, 20)) {
8092
8571
  const href = otherPageHref(p.path, location);
@@ -8105,7 +8584,19 @@ var NuniWidget = class {
8105
8584
  type: "button",
8106
8585
  "aria-label": this.t("close"),
8107
8586
  onclick: () => this.togglePanel(false)
8108
- }, icon(ICONS.close))), h("div", {
8587
+ }, icon(ICONS.close))), this.comments.length || this.mine?.length ? h("div", { class: "panel-search" }, icon(ICONS.search), h("input", {
8588
+ class: "field",
8589
+ type: "search",
8590
+ value: this.search,
8591
+ placeholder: this.t("searchComments"),
8592
+ "aria-label": this.t("searchComments"),
8593
+ "aria-controls": "nuni-panel-list",
8594
+ "data-focus-key": "panel-search",
8595
+ oninput: (e) => {
8596
+ this.search = e.target.value;
8597
+ this.render();
8598
+ }
8599
+ })) : null, h("div", {
8109
8600
  class: "tabs",
8110
8601
  role: "tablist",
8111
8602
  "aria-label": this.t("comments"),
@@ -8118,11 +8609,13 @@ var NuniWidget = class {
8118
8609
  "End"
8119
8610
  ].includes(key)) return;
8120
8611
  e.preventDefault();
8121
- 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];
8122
8615
  this.focusNext = `[data-focus-key="tab-${this.tab}"]`;
8123
8616
  this.render();
8124
8617
  }
8125
- }, ["open", "resolved"].map((tab) => h("button", {
8618
+ }, PANEL_TABS.map((tab) => h("button", {
8126
8619
  class: "tab",
8127
8620
  role: "tab",
8128
8621
  type: "button",
@@ -8135,7 +8628,7 @@ var NuniWidget = class {
8135
8628
  this.tab = tab;
8136
8629
  this.render();
8137
8630
  }
8138
- }, 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", {
8139
8632
  class: "panel-list",
8140
8633
  id: "nuni-panel-list",
8141
8634
  role: "tabpanel",
@@ -8175,14 +8668,25 @@ var NuniWidget = class {
8175
8668
  "data-focus-key": "name",
8176
8669
  "aria-label": this.t("yourName")
8177
8670
  });
8671
+ const before = draft.suggestFrom ?? null;
8672
+ const suggesting = Boolean(draft.suggesting) && before !== null;
8178
8673
  const textarea = h("textarea", {
8179
8674
  class: "cmp-text",
8180
- placeholder: this.t("leaveComment"),
8675
+ placeholder: this.t(suggesting ? "addNote" : "leaveComment"),
8181
8676
  maxlength: LIMITS.bodyMaxLength,
8182
8677
  rows: 3,
8183
8678
  "data-focus-key": "body",
8184
8679
  "aria-label": this.t("comment")
8185
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);
8186
8690
  const prev = this.uiLayer.querySelector("[data-focus-key=\"body\"]");
8187
8691
  if (prev) textarea.value = prev.value;
8188
8692
  const prevName = this.uiLayer.querySelector("[data-focus-key=\"name\"]");
@@ -8196,31 +8700,68 @@ var NuniWidget = class {
8196
8700
  const post = h("button", {
8197
8701
  class: "cmp-post",
8198
8702
  type: "submit",
8199
- disabled: Boolean(draft.sending) || !textarea.value.trim()
8703
+ disabled: Boolean(draft.sending) || !canPost()
8200
8704
  }, draft.sending ? this.t("posting") : this.t("post"), icon(ICONS.arrowUp));
8201
8705
  const update = () => {
8202
- post.disabled = Boolean(draft.sending) || !textarea.value.trim();
8706
+ post.disabled = Boolean(draft.sending) || !canPost();
8203
8707
  const left = LIMITS.bodyMaxLength - textarea.value.length;
8204
8708
  counter.textContent = left <= LIMITS.bodyMaxLength * .1 ? this.t("charsLeft", { count: left }) : "";
8205
- textarea.style.height = "auto";
8206
- 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
+ }
8207
8714
  };
8208
8715
  textarea.addEventListener("input", () => {
8209
8716
  update();
8210
8717
  this.positionCard();
8211
8718
  });
8719
+ suggestInput?.addEventListener("input", () => {
8720
+ draft.suggestion = suggestInput.value;
8721
+ update();
8722
+ this.positionCard();
8723
+ });
8212
8724
  requestAnimationFrame(() => {
8213
8725
  if (!textarea.isConnected) return;
8214
8726
  update();
8215
8727
  this.positionCard();
8216
8728
  });
8217
8729
  const submit = () => void this.submitDraft(draft, textarea.value, needsName ? nameInput.value : this.name);
8218
- textarea.addEventListener("keydown", (e) => {
8730
+ for (const field of [textarea, suggestInput]) field?.addEventListener("keydown", (e) => {
8219
8731
  if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
8220
8732
  e.preventDefault();
8221
8733
  submit();
8222
8734
  }
8223
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
+ };
8224
8765
  return h("form", {
8225
8766
  class: `${cls} composer`,
8226
8767
  "data-card": "composer",
@@ -8236,13 +8777,48 @@ var NuniWidget = class {
8236
8777
  this.render();
8237
8778
  this.focusComposer();
8238
8779
  }
8239
- }, 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", {
8240
8799
  class: "error cmp-error",
8241
8800
  role: "alert"
8242
8801
  }, draft.error) : null, h("div", { class: "cmp-foot" }, h("span", {
8243
8802
  class: "cmp-keys",
8244
8803
  title: this.t("send", { key: this.i18n.mod })
8245
- }, 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", {
8246
8822
  class: "icon-btn cmp-close",
8247
8823
  type: "button",
8248
8824
  "aria-label": this.t("cancel"),
@@ -8265,11 +8841,24 @@ var NuniWidget = class {
8265
8841
  const textarea = h("textarea", {
8266
8842
  class: "field",
8267
8843
  maxlength: LIMITS.bodyMaxLength,
8268
- "data-focus-key": "edit"
8844
+ placeholder: comment.suggestion ? this.t("addNote") : null,
8845
+ "data-focus-key": "edit",
8846
+ "aria-label": this.t("comment")
8269
8847
  });
8270
8848
  const prev = this.uiLayer.querySelector("[data-focus-key=\"edit\"]");
8271
8849
  textarea.value = prev ? prev.value : comment.body;
8272
- 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", {
8273
8862
  class: "btn btn-ghost",
8274
8863
  type: "button",
8275
8864
  onclick: () => {
@@ -8280,11 +8869,11 @@ var NuniWidget = class {
8280
8869
  class: "btn btn-primary",
8281
8870
  type: "button",
8282
8871
  disabled: Boolean(card.busy),
8283
- onclick: () => this.saveEdit(comment, textarea.value)
8872
+ onclick: () => this.saveEdit(comment, textarea.value, suggestField?.value)
8284
8873
  }, this.t("save"))));
8285
8874
  } else {
8286
8875
  const badges = [
8287
- 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,
8288
8877
  comment.page.origin !== location.origin ? h("span", {
8289
8878
  class: "badge",
8290
8879
  title: this.t("leftOn", { origin: comment.page.origin })
@@ -8313,7 +8902,16 @@ var NuniWidget = class {
8313
8902
  "aria-label": this.t("close"),
8314
8903
  title: this.t("close"),
8315
8904
  onclick: () => this.closeCard()
8316
- }, 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", {
8317
8915
  class: "shot",
8318
8916
  href: this.ownerDetail.screenshotUrl,
8319
8917
  target: "_blank",
@@ -8322,14 +8920,29 @@ var NuniWidget = class {
8322
8920
  }, h("img", {
8323
8921
  src: this.ownerDetail.screenshotUrl,
8324
8922
  alt: this.t("screenshotTaken")
8325
- })) : 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", {
8326
8933
  class: "icon-btn",
8327
8934
  type: "button",
8328
8935
  "aria-label": this.t("copyForAgent"),
8329
8936
  title: contextPending ? this.t("loadingContext") : this.t("copyForAgentHint"),
8330
8937
  disabled: contextPending,
8331
8938
  onclick: () => this.copyForAgent(comment)
8332
- }, 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", {
8333
8946
  class: "icon-btn",
8334
8947
  type: "button",
8335
8948
  "aria-label": this.t("edit"),
@@ -8543,8 +9156,8 @@ var NuniWidget = class {
8543
9156
  top = point.y - 28;
8544
9157
  if (left + width > vw - 12) left = point.x - width - 12;
8545
9158
  } else {
8546
- const { position } = this.config;
8547
- left = position.endsWith("left") ? 16 : vw - width - 16;
9159
+ const { position } = this;
9160
+ left = position.endsWith("left") ? 16 : position.endsWith("center") ? (vw - width) / 2 : vw - width - 16;
8548
9161
  top = position.startsWith("top") ? 72 : vh - height - 72;
8549
9162
  }
8550
9163
  left = Math.max(12, Math.min(left, vw - width - 12));
@@ -8558,6 +9171,22 @@ var NuniWidget = class {
8558
9171
  });
8559
9172
  }
8560
9173
  };
9174
+ /** The toolbar spot nearest a point: thirds across the screen, halves down. */
9175
+ function nearestPosition(x, y) {
9176
+ const width = window.innerWidth;
9177
+ const across = x < width / 3 ? "left" : x > width * 2 / 3 ? "right" : "center";
9178
+ return `${y < window.innerHeight / 2 ? "top" : "bottom"}-${across}`;
9179
+ }
9180
+ /** A press on a scrollbar, which scrolls rather than points at the page. */
9181
+ function onScrollbar(e) {
9182
+ const el = e.composedPath()[0];
9183
+ if (!(el instanceof Element)) return false;
9184
+ const root = document.documentElement;
9185
+ if (el === root) return e.clientX >= root.clientWidth || e.clientY >= root.clientHeight;
9186
+ if (!(el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth)) return false;
9187
+ const box = el.getBoundingClientRect();
9188
+ return e.clientX - box.left - el.clientLeft >= el.clientWidth || e.clientY - box.top - el.clientTop >= el.clientHeight;
9189
+ }
8561
9190
  //#endregion
8562
9191
  //#region src/mount.ts
8563
9192
  /** Nuni's own requests are not the page's problem. */
@@ -8640,7 +9269,7 @@ function unmount(state) {
8640
9269
  * down the widget it was created for.
8641
9270
  */
8642
9271
  function init(options) {
8643
- return mount(options, { loadScreenshot: () => import("./screenshot-DcJ_SzKF.js").then((m) => m.captureScreenshot) });
9272
+ return mount(options, { loadScreenshot: () => import("./tools-gIAPu66h.js").then((m) => m.screenshotTools) });
8644
9273
  }
8645
9274
  //#endregion
8646
9275
  export { VERSION, init, EN as messages };