@loupekit/sdk 0.1.0 → 0.2.0-next.1

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.
@@ -32,7 +32,9 @@ var Loupe = (() => {
32
32
  :host { all: initial; }
33
33
  * { box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
34
34
 
35
- :root, .loupe {
35
+ /* Custom properties must live on :host \u2014 inside the Shadow DOM, :root matches
36
+ nothing and no element carries a .loupe class, so they'd never resolve. */
37
+ :host {
36
38
  --accent: #4a55d6;
37
39
  --pin: #ff5842;
38
40
  --ink: #16181f;
@@ -60,6 +62,17 @@ var Loupe = (() => {
60
62
  font-family: ui-monospace, Menlo, monospace;
61
63
  }
62
64
 
65
+ /* region selection (during drag) + active-comment outline */
66
+ .selbox {
67
+ position: fixed; pointer-events: none; z-index: 2147483001; display: none;
68
+ border: 2px dashed var(--accent); background: rgba(74, 85, 214, 0.12); border-radius: 4px;
69
+ }
70
+ .region-box {
71
+ position: fixed; pointer-events: none; z-index: 2147483001; display: none;
72
+ border: 2px solid var(--pin); border-radius: 4px;
73
+ box-shadow: 0 0 0 2px rgba(255, 88, 66, .25), 0 4px 16px rgba(0,0,0,.25);
74
+ }
75
+
63
76
  /* pins */
64
77
  .pin {
65
78
  position: fixed; pointer-events: auto; z-index: 2147483002;
@@ -89,6 +102,7 @@ var Loupe = (() => {
89
102
  display: flex; align-items: center; gap: 6px;
90
103
  }
91
104
  .toolbar button:hover { background: #2b2f3b; color: #fff; }
105
+ .toolbar button .ico { flex: none; display: block; }
92
106
  .toolbar button.on { background: var(--accent); color: #fff; }
93
107
  .toolbar .sep { width: 1px; height: 20px; background: #333846; margin: 0 2px; }
94
108
  .toolbar .brand { font-weight: 700; padding-left: 8px; padding-right: 4px; letter-spacing: -.01em; }
@@ -1943,6 +1957,50 @@ var Loupe = (() => {
1943
1957
  return void 0;
1944
1958
  }
1945
1959
  }
1960
+ async function captureRegionScreenshot(rect) {
1961
+ try {
1962
+ const scale = Math.min(window.devicePixelRatio || 1, 2);
1963
+ const full = await domToPng(document.body, {
1964
+ scale,
1965
+ backgroundColor: getComputedStyle(document.body).backgroundColor || "#ffffff",
1966
+ filter: (node) => {
1967
+ if (!(node instanceof Element)) return true;
1968
+ if (node.id === "loupe-root") return false;
1969
+ if (node.hasAttribute("data-loupe-redact")) return false;
1970
+ return true;
1971
+ }
1972
+ });
1973
+ const bodyRect = document.body.getBoundingClientRect();
1974
+ const redact = Array.from(document.querySelectorAll("[data-loupe-redact]")).map(
1975
+ (n) => n.getBoundingClientRect()
1976
+ );
1977
+ return await cropRegion(full, rect, bodyRect.left, bodyRect.top, scale, redact);
1978
+ } catch (err) {
1979
+ console.warn("[loupe] region capture failed", err);
1980
+ return void 0;
1981
+ }
1982
+ }
1983
+ function cropRegion(dataUrl, rect, ox, oy, scale, redact) {
1984
+ return new Promise((resolve, reject) => {
1985
+ const img = new Image();
1986
+ img.onload = () => {
1987
+ const sw = Math.max(1, Math.round(rect.w * scale));
1988
+ const sh = Math.max(1, Math.round(rect.h * scale));
1989
+ const canvas = document.createElement("canvas");
1990
+ canvas.width = sw;
1991
+ canvas.height = sh;
1992
+ const ctx = canvas.getContext("2d");
1993
+ ctx.drawImage(img, (rect.x - ox) * scale, (rect.y - oy) * scale, sw, sh, 0, 0, sw, sh);
1994
+ ctx.fillStyle = "#0f0f14";
1995
+ for (const r of redact) {
1996
+ ctx.fillRect((r.left - rect.x) * scale, (r.top - rect.y) * scale, r.width * scale, r.height * scale);
1997
+ }
1998
+ resolve(canvas.toDataURL("image/png"));
1999
+ };
2000
+ img.onerror = reject;
2001
+ img.src = dataUrl;
2002
+ });
2003
+ }
1946
2004
 
1947
2005
  // src/store.ts
1948
2006
  var LocalStorageAdapter = class {
@@ -1998,56 +2056,63 @@ var Loupe = (() => {
1998
2056
 
1999
2057
  // src/http-adapter.ts
2000
2058
  var HttpAdapter = class {
2001
- constructor(base, user, userHmac) {
2059
+ constructor(base, user, userHmac, extraHeaders, credentials) {
2002
2060
  this.base = base;
2003
2061
  this.user = user;
2004
2062
  this.userHmac = userHmac;
2063
+ this.extraHeaders = extraHeaders;
2064
+ this.credentials = credentials;
2005
2065
  this.base = base.replace(/\/$/, "");
2006
2066
  }
2007
2067
  headers() {
2008
2068
  const h = { "Content-Type": "application/json", "X-Loupe-User": this.user.id };
2009
2069
  if (this.userHmac) h["X-Loupe-Hmac"] = this.userHmac;
2070
+ if (this.extraHeaders) Object.assign(h, this.extraHeaders);
2010
2071
  return h;
2011
2072
  }
2073
+ /** Base fetch options shared by every request (credentials mode, if set). */
2074
+ opts(init2) {
2075
+ return this.credentials ? { credentials: this.credentials, ...init2 } : { ...init2 };
2076
+ }
2012
2077
  async list(projectKey, url) {
2013
2078
  const q = new URLSearchParams({ projectKey, url });
2014
- const res = await fetch(`${this.base}/v1/comments?${q}`, { headers: this.headers() });
2079
+ const res = await fetch(`${this.base}/v1/comments?${q}`, this.opts({ headers: this.headers() }));
2015
2080
  if (!res.ok) throw new Error(`list failed: ${res.status}`);
2016
2081
  return await res.json();
2017
2082
  }
2018
2083
  async save(comment) {
2019
2084
  if (comment.screenshot?.startsWith("data:")) {
2020
2085
  try {
2021
- const up = await fetch(`${this.base}/v1/blobs`, {
2086
+ const up = await fetch(`${this.base}/v1/blobs`, this.opts({
2022
2087
  method: "POST",
2023
2088
  headers: this.headers(),
2024
2089
  body: JSON.stringify({ projectKey: comment.projectKey, data: comment.screenshot })
2025
- });
2090
+ }));
2026
2091
  if (up.ok) comment = { ...comment, screenshot: (await up.json()).url };
2027
2092
  } catch {
2028
2093
  }
2029
2094
  }
2030
- const res = await fetch(`${this.base}/v1/comments`, {
2095
+ const res = await fetch(`${this.base}/v1/comments`, this.opts({
2031
2096
  method: "POST",
2032
2097
  headers: this.headers(),
2033
2098
  body: JSON.stringify(comment)
2034
- });
2099
+ }));
2035
2100
  if (!res.ok) throw new Error(`save failed: ${res.status}`);
2036
2101
  return await res.json();
2037
2102
  }
2038
2103
  async update(id, patch) {
2039
- const res = await fetch(`${this.base}/v1/comments/${encodeURIComponent(id)}`, {
2104
+ const res = await fetch(`${this.base}/v1/comments/${encodeURIComponent(id)}`, this.opts({
2040
2105
  method: "PATCH",
2041
2106
  headers: this.headers(),
2042
2107
  body: JSON.stringify(patch)
2043
- });
2108
+ }));
2044
2109
  if (!res.ok) throw new Error(`update failed: ${res.status}`);
2045
2110
  }
2046
2111
  async remove(id) {
2047
- const res = await fetch(`${this.base}/v1/comments/${encodeURIComponent(id)}`, {
2112
+ const res = await fetch(`${this.base}/v1/comments/${encodeURIComponent(id)}`, this.opts({
2048
2113
  method: "DELETE",
2049
2114
  headers: this.headers()
2050
- });
2115
+ }));
2051
2116
  if (!res.ok && res.status !== 404) throw new Error(`remove failed: ${res.status}`);
2052
2117
  }
2053
2118
  };
@@ -2066,14 +2131,16 @@ var Loupe = (() => {
2066
2131
  this.resolved = /* @__PURE__ */ new Map();
2067
2132
  /** comment.id → pin element. */
2068
2133
  this.pins = /* @__PURE__ */ new Map();
2069
- this.inspecting = false;
2070
- this.target = null;
2134
+ this.mode = "off";
2071
2135
  this.targetOffset = { x: 0.5, y: 0.5 };
2072
- this.activeId = null;
2136
+ this.pending = null;
2137
+ this.dragStart = null;
2138
+ /** region comment whose outline is currently highlighted (tracks scroll). */
2139
+ this.activeRegionId = null;
2073
2140
  this.raf = 0;
2074
2141
  // ---- inspector ------------------------------------------------------------
2075
2142
  this.onMove = (e) => {
2076
- if (!this.inspecting) return;
2143
+ if (this.mode !== "inspect") return;
2077
2144
  const target = this.pick(e.clientX, e.clientY);
2078
2145
  if (!target) {
2079
2146
  this.hl.style.display = "none";
@@ -2090,7 +2157,7 @@ var Loupe = (() => {
2090
2157
  this.hl.firstChild.textContent = target.tagName.toLowerCase() + (target.id ? "#" + target.id : "");
2091
2158
  };
2092
2159
  this.onClick = (e) => {
2093
- if (!this.inspecting) return;
2160
+ if (this.mode !== "inspect") return;
2094
2161
  const target = this.pick(e.clientX, e.clientY);
2095
2162
  if (!target) return;
2096
2163
  e.preventDefault();
@@ -2100,16 +2167,50 @@ var Loupe = (() => {
2100
2167
  x: r.width ? clamp((e.clientX - r.left) / r.width) : 0.5,
2101
2168
  y: r.height ? clamp((e.clientY - r.top) / r.height) : 0.5
2102
2169
  };
2103
- this.target = target;
2104
- this.setInspecting(false);
2105
- this.openComposer(target, e.clientX, e.clientY);
2170
+ this.setMode("off");
2171
+ this.openComposer({ kind: "element", element: target }, e.clientX, e.clientY);
2106
2172
  };
2107
2173
  this.onKey = (e) => {
2108
2174
  if (e.key === "Escape") {
2109
- this.setInspecting(false);
2175
+ this.cancelDrag();
2176
+ this.setMode("off");
2110
2177
  this.closeComposer();
2111
2178
  }
2112
2179
  };
2180
+ // ---- region ("free-size screenshot") selection ----------------------------
2181
+ this.onRegionDown = (e) => {
2182
+ if (this.mode !== "region" || e.button !== 0) return;
2183
+ e.preventDefault();
2184
+ e.stopPropagation();
2185
+ this.dragStart = { x: e.clientX, y: e.clientY };
2186
+ document.addEventListener("mousemove", this.onRegionMove, true);
2187
+ document.addEventListener("mouseup", this.onRegionUp, true);
2188
+ this.drawSelection(e.clientX, e.clientY);
2189
+ };
2190
+ this.onRegionMove = (e) => {
2191
+ if (!this.dragStart) return;
2192
+ e.preventDefault();
2193
+ this.drawSelection(e.clientX, e.clientY);
2194
+ };
2195
+ this.onRegionUp = (e) => {
2196
+ if (!this.dragStart) return;
2197
+ e.preventDefault();
2198
+ e.stopPropagation();
2199
+ const start = this.dragStart;
2200
+ this.cancelDrag();
2201
+ const vp = {
2202
+ x: Math.min(start.x, e.clientX),
2203
+ y: Math.min(start.y, e.clientY),
2204
+ w: Math.abs(e.clientX - start.x),
2205
+ h: Math.abs(e.clientY - start.y)
2206
+ };
2207
+ if (vp.w < 8 || vp.h < 8) {
2208
+ this.selbox.style.display = "none";
2209
+ return;
2210
+ }
2211
+ this.setMode("off");
2212
+ this.finishRegion(vp);
2213
+ };
2113
2214
  /** Reposition every pin, re-resolving anchors whose element has gone. */
2114
2215
  this.position = () => {
2115
2216
  for (const c of this.comments) {
@@ -2121,6 +2222,18 @@ var Loupe = (() => {
2121
2222
  elx = r?.element ?? null;
2122
2223
  this.resolved.set(c.id, elx);
2123
2224
  }
2225
+ if (c.kind === "region") {
2226
+ const box = this.regionRect(c, elx);
2227
+ if (box) {
2228
+ const onScreen = box.x + box.w > 0 && box.x < window.innerWidth && box.y + box.h > 0 && box.y < window.innerHeight;
2229
+ Object.assign(pin.style, { left: box.x + "px", top: box.y + "px", display: onScreen ? "grid" : "none" });
2230
+ pin.classList.toggle("detached", !elx && !c.region?.rel);
2231
+ } else {
2232
+ pin.style.display = "none";
2233
+ pin.classList.add("detached");
2234
+ }
2235
+ continue;
2236
+ }
2124
2237
  if (elx) {
2125
2238
  const rect = elx.getBoundingClientRect();
2126
2239
  const px = rect.left + c.offset.x * rect.width;
@@ -2133,9 +2246,22 @@ var Loupe = (() => {
2133
2246
  pin.classList.add("detached");
2134
2247
  }
2135
2248
  }
2249
+ if (this.activeRegionId) {
2250
+ const c = this.comments.find((x) => x.id === this.activeRegionId);
2251
+ const box = c && this.regionRect(c, this.resolved.get(c.id) ?? null);
2252
+ if (box) {
2253
+ Object.assign(this.regionBox.style, {
2254
+ display: "block",
2255
+ left: box.x + "px",
2256
+ top: box.y + "px",
2257
+ width: box.w + "px",
2258
+ height: box.h + "px"
2259
+ });
2260
+ }
2261
+ }
2136
2262
  };
2137
2263
  this.cfg = cfg;
2138
- this.store = cfg.apiBase ? new HttpAdapter(cfg.apiBase, cfg.user, cfg.userHmac) : new LocalStorageAdapter();
2264
+ this.store = cfg.apiBase ? new HttpAdapter(cfg.apiBase, cfg.user, cfg.userHmac, cfg.headers, cfg.credentials) : new LocalStorageAdapter();
2139
2265
  }
2140
2266
  get url() {
2141
2267
  return location.pathname + location.search;
@@ -2146,7 +2272,7 @@ var Loupe = (() => {
2146
2272
  this.renderPins();
2147
2273
  this.renderPanel();
2148
2274
  this.observe();
2149
- if (this.cfg.autoOpen) this.setInspecting(true);
2275
+ if (this.cfg.autoOpen) this.setMode("inspect");
2150
2276
  }
2151
2277
  // ---- DOM construction -----------------------------------------------------
2152
2278
  buildDom() {
@@ -2160,7 +2286,9 @@ var Loupe = (() => {
2160
2286
  this.overlay = el("div", "overlay");
2161
2287
  this.hl = el("div", "hl");
2162
2288
  this.hl.appendChild(el("span", "tip"));
2163
- this.overlay.appendChild(this.hl);
2289
+ this.selbox = el("div", "selbox");
2290
+ this.regionBox = el("div", "region-box");
2291
+ this.overlay.append(this.hl, this.selbox, this.regionBox);
2164
2292
  this.shadow.appendChild(this.overlay);
2165
2293
  this.composer = el("div", "composer");
2166
2294
  this.shadow.appendChild(this.composer);
@@ -2174,14 +2302,55 @@ var Loupe = (() => {
2174
2302
  const brand = el("span", "brand", "\u25CE Loupe");
2175
2303
  const inspectBtn = el("button", "", "\u271B Inspect & comment");
2176
2304
  inspectBtn.dataset.role = "inspect";
2177
- inspectBtn.onclick = () => this.setInspecting(!this.inspecting);
2305
+ inspectBtn.onclick = () => this.setMode(this.mode === "inspect" ? "off" : "inspect");
2306
+ const regionBtn = el("button");
2307
+ regionBtn.innerHTML = `${REGION_ICON}<span>Region shot</span>`;
2308
+ regionBtn.dataset.role = "region";
2309
+ regionBtn.title = "Drag a free-size box, screenshot it, and comment";
2310
+ regionBtn.onclick = () => this.setMode(this.mode === "region" ? "off" : "region");
2178
2311
  const listBtn = el("button", "", "\u2630 Comments");
2179
2312
  this.countEl = el("span", "count", "0");
2180
2313
  listBtn.appendChild(this.countEl);
2181
2314
  listBtn.onclick = () => this.togglePanel();
2182
- bar.append(brand, sep(), inspectBtn, listBtn);
2315
+ bar.append(brand, sep(), inspectBtn, regionBtn, listBtn);
2183
2316
  return bar;
2184
2317
  }
2318
+ drawSelection(curX, curY) {
2319
+ const s = this.dragStart;
2320
+ Object.assign(this.selbox.style, {
2321
+ display: "block",
2322
+ left: Math.min(s.x, curX) + "px",
2323
+ top: Math.min(s.y, curY) + "px",
2324
+ width: Math.abs(curX - s.x) + "px",
2325
+ height: Math.abs(curY - s.y) + "px"
2326
+ });
2327
+ }
2328
+ cancelDrag() {
2329
+ this.dragStart = null;
2330
+ document.removeEventListener("mousemove", this.onRegionMove, true);
2331
+ document.removeEventListener("mouseup", this.onRegionUp, true);
2332
+ }
2333
+ /** Capture the selected viewport rect, then open the composer for a region comment. */
2334
+ async finishRegion(vp) {
2335
+ const centerEl = this.pick(vp.x + vp.w / 2, vp.y + vp.h / 2);
2336
+ let rel;
2337
+ if (centerEl) {
2338
+ const er = centerEl.getBoundingClientRect();
2339
+ if (er.width > 0 && er.height > 0) {
2340
+ rel = {
2341
+ fx: (vp.x - er.left) / er.width,
2342
+ fy: (vp.y - er.top) / er.height,
2343
+ fw: vp.w / er.width,
2344
+ fh: vp.h / er.height
2345
+ };
2346
+ }
2347
+ }
2348
+ const capture = this.cfg.captureRegion ?? captureRegionScreenshot;
2349
+ const screenshot = await capture(vp);
2350
+ this.selbox.style.display = "none";
2351
+ const region = { x: vp.x + window.scrollX, y: vp.y + window.scrollY, w: vp.w, h: vp.h, rel };
2352
+ this.openComposer({ kind: "region", region, element: centerEl, screenshot }, vp.x + vp.w, vp.y);
2353
+ }
2185
2354
  /** elementFromPoint, ignoring our own UI. */
2186
2355
  pick(x, y) {
2187
2356
  const hitHl = this.hl.style.display;
@@ -2192,34 +2361,45 @@ var Loupe = (() => {
2192
2361
  if (elAt.id === "loupe-root" || elAt.closest?.("#loupe-root")) return null;
2193
2362
  return elAt;
2194
2363
  }
2195
- setInspecting(on) {
2196
- this.inspecting = on;
2364
+ setMode(mode) {
2365
+ this.mode = mode;
2197
2366
  this.hl.style.display = "none";
2198
- document.body.style.cursor = on ? "crosshair" : "";
2199
- const btn = this.toolbar.querySelector('[data-role="inspect"]');
2200
- btn.classList.toggle("on", on);
2201
- if (on) {
2367
+ this.selbox.style.display = "none";
2368
+ this.cancelDrag();
2369
+ document.body.style.cursor = mode === "off" ? "" : "crosshair";
2370
+ this.toolbar.querySelector('[data-role="inspect"]')?.classList.toggle("on", mode === "inspect");
2371
+ this.toolbar.querySelector('[data-role="region"]')?.classList.toggle("on", mode === "region");
2372
+ document.removeEventListener("mousemove", this.onMove, true);
2373
+ document.removeEventListener("click", this.onClick, true);
2374
+ document.removeEventListener("mousedown", this.onRegionDown, true);
2375
+ if (mode === "off") return;
2376
+ document.addEventListener("keydown", this.onKey, true);
2377
+ this.closeComposer();
2378
+ if (mode === "inspect") {
2202
2379
  document.addEventListener("mousemove", this.onMove, true);
2203
2380
  document.addEventListener("click", this.onClick, true);
2204
- document.addEventListener("keydown", this.onKey, true);
2205
- this.closeComposer();
2206
- } else {
2207
- document.removeEventListener("mousemove", this.onMove, true);
2208
- document.removeEventListener("click", this.onClick, true);
2381
+ } else if (mode === "region") {
2382
+ document.addEventListener("mousedown", this.onRegionDown, true);
2209
2383
  }
2210
2384
  }
2211
2385
  // ---- composer -------------------------------------------------------------
2212
2386
  openComposer(target, x, y) {
2387
+ this.pending = target;
2213
2388
  const c = this.composer;
2214
2389
  c.innerHTML = "";
2215
- const label = el("div", "target", describe(target));
2390
+ const label = el(
2391
+ "div",
2392
+ "target",
2393
+ target.kind === "element" ? describe(target.element) : `Region \xB7 ${Math.round(target.region.w)}\xD7${Math.round(target.region.h)} px`
2394
+ );
2216
2395
  const ta = el("textarea");
2217
- ta.placeholder = "What should change here?";
2396
+ ta.placeholder = target.kind === "region" ? "What's the issue in this area?" : "What should change here?";
2218
2397
  const row = el("div", "row");
2219
2398
  const chk = el("label", "chk");
2220
2399
  const box = document.createElement("input");
2221
2400
  box.type = "checkbox";
2222
- box.checked = true;
2401
+ box.checked = target.kind === "region" ? !!target.screenshot : true;
2402
+ if (target.kind === "region" && !target.screenshot) box.disabled = true;
2223
2403
  chk.append(box, document.createTextNode("Attach screenshot"));
2224
2404
  const btns = el("div", "btns");
2225
2405
  const cancel = el("button", "ghost", "Cancel");
@@ -2241,7 +2421,7 @@ var Loupe = (() => {
2241
2421
  }
2242
2422
  closeComposer() {
2243
2423
  this.composer.style.display = "none";
2244
- this.target = null;
2424
+ this.pending = null;
2245
2425
  }
2246
2426
  async submit(target, body, withShot) {
2247
2427
  if (!body) return;
@@ -2250,8 +2430,30 @@ var Loupe = (() => {
2250
2430
  saveBtn.disabled = true;
2251
2431
  saveBtn.textContent = "Saving\u2026";
2252
2432
  }
2253
- const capture = this.cfg.captureScreenshot ?? captureScreenshot;
2254
- const screenshot = withShot ? await capture(target) : void 0;
2433
+ let anchor, context, offset;
2434
+ let screenshot;
2435
+ let region;
2436
+ let anchoredEl = null;
2437
+ if (target.kind === "element") {
2438
+ const capture = this.cfg.captureScreenshot ?? captureScreenshot;
2439
+ screenshot = withShot ? await capture(target.element) : void 0;
2440
+ anchor = captureAnchor(target.element);
2441
+ context = captureElementContext(target.element);
2442
+ offset = this.targetOffset;
2443
+ anchoredEl = target.element;
2444
+ } else {
2445
+ screenshot = withShot ? target.screenshot : void 0;
2446
+ region = target.region;
2447
+ offset = { x: 0, y: 0 };
2448
+ if (target.element) {
2449
+ anchor = captureAnchor(target.element);
2450
+ context = captureElementContext(target.element);
2451
+ anchoredEl = target.element;
2452
+ } else {
2453
+ anchor = regionAnchor(region);
2454
+ context = { html: regionNote(region), styles: {} };
2455
+ }
2456
+ }
2255
2457
  const comment = {
2256
2458
  id: uid2(),
2257
2459
  projectKey: this.cfg.projectKey,
@@ -2259,15 +2461,17 @@ var Loupe = (() => {
2259
2461
  author: this.cfg.user,
2260
2462
  body,
2261
2463
  status: "open",
2262
- anchor: captureAnchor(target),
2263
- context: captureElementContext(target),
2264
- offset: this.targetOffset,
2464
+ kind: target.kind,
2465
+ anchor,
2466
+ context,
2467
+ offset,
2468
+ region,
2265
2469
  screenshot,
2266
2470
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
2267
2471
  };
2268
2472
  await this.store.save(comment);
2269
2473
  this.comments.push(comment);
2270
- this.resolved.set(comment.id, target);
2474
+ this.resolved.set(comment.id, anchoredEl);
2271
2475
  this.closeComposer();
2272
2476
  this.renderPins();
2273
2477
  this.renderPanel();
@@ -2298,6 +2502,22 @@ var Loupe = (() => {
2298
2502
  this.countEl.textContent = String(this.comments.length);
2299
2503
  this.position();
2300
2504
  }
2505
+ /**
2506
+ * The current viewport rect for a region comment. Prefers the element-relative
2507
+ * fractions (so it tracks reflow across viewports); falls back to the stored
2508
+ * document coordinates minus scroll. Returns null if neither is available.
2509
+ */
2510
+ regionRect(c, elx) {
2511
+ const rel = c.region?.rel;
2512
+ if (elx && rel) {
2513
+ const r = elx.getBoundingClientRect();
2514
+ return { x: r.left + rel.fx * r.width, y: r.top + rel.fy * r.height, w: rel.fw * r.width, h: rel.fh * r.height };
2515
+ }
2516
+ if (c.region) {
2517
+ return { x: c.region.x - window.scrollX, y: c.region.y - window.scrollY, w: c.region.w, h: c.region.h };
2518
+ }
2519
+ return null;
2520
+ }
2301
2521
  observe() {
2302
2522
  const reposition = () => {
2303
2523
  cancelAnimationFrame(this.raf);
@@ -2414,7 +2634,23 @@ var Loupe = (() => {
2414
2634
  }
2415
2635
  }
2416
2636
  flash(id) {
2637
+ const c = this.comments.find((x) => x.id === id);
2417
2638
  const pin = this.pins.get(id);
2639
+ if (c?.kind === "region" && c.region) {
2640
+ for (const p of this.pins.values()) p.classList.remove("active");
2641
+ pin?.classList.add("active");
2642
+ this.activeRegionId = id;
2643
+ window.clearTimeout(this.regionTimer);
2644
+ this.regionTimer = window.setTimeout(() => {
2645
+ this.activeRegionId = null;
2646
+ this.regionBox.style.display = "none";
2647
+ }, 2400);
2648
+ const elx2 = this.resolved.get(id);
2649
+ if (elx2) elx2.scrollIntoView({ behavior: "smooth", block: "center" });
2650
+ else window.scrollTo({ top: Math.max(0, c.region.y - 120), left: Math.max(0, c.region.x - 120), behavior: "smooth" });
2651
+ this.position();
2652
+ return;
2653
+ }
2418
2654
  if (!pin || pin.classList.contains("detached")) return;
2419
2655
  for (const p of this.pins.values()) p.classList.remove("active");
2420
2656
  pin.classList.add("active");
@@ -2422,9 +2658,10 @@ var Loupe = (() => {
2422
2658
  if (elx) elx.scrollIntoView({ behavior: "smooth", block: "center" });
2423
2659
  }
2424
2660
  destroy() {
2425
- this.setInspecting(false);
2661
+ this.setMode("off");
2426
2662
  this.mo?.disconnect();
2427
2663
  if (this.tick) clearInterval(this.tick);
2664
+ window.clearTimeout(this.regionTimer);
2428
2665
  document.removeEventListener("keydown", this.onKey, true);
2429
2666
  this.root?.remove();
2430
2667
  }
@@ -2441,6 +2678,23 @@ var Loupe = (() => {
2441
2678
  function clamp(n) {
2442
2679
  return Math.max(0, Math.min(1, n));
2443
2680
  }
2681
+ var REGION_ICON = `<svg class="ico" width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true"><rect x="1.5" y="2.5" width="12" height="10" rx="1.5" stroke="currentColor" stroke-width="1.4" stroke-dasharray="2.4 1.8"/></svg>`;
2682
+ function regionAnchor(region) {
2683
+ return {
2684
+ tag: "region",
2685
+ cssPath: `region ${Math.round(region.w)}\xD7${Math.round(region.h)}`,
2686
+ xpath: "",
2687
+ testid: null,
2688
+ text: "",
2689
+ attrs: {},
2690
+ nthOfType: 1,
2691
+ rect: { x: Math.round(region.x), y: Math.round(region.y), w: Math.round(region.w), h: Math.round(region.h) },
2692
+ viewport: { w: window.innerWidth, h: window.innerHeight }
2693
+ };
2694
+ }
2695
+ function regionNote(region) {
2696
+ return `<!-- Loupe free-region annotation: ${Math.round(region.w)}\xD7${Math.round(region.h)}px at document (${Math.round(region.x)}, ${Math.round(region.y)}). No single DOM element \u2014 see the attached screenshot. -->`;
2697
+ }
2444
2698
  function describe(elx) {
2445
2699
  const testid = elx.getAttribute("data-testid") || elx.getAttribute("data-test");
2446
2700
  const tag = elx.tagName.toLowerCase();