@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.
package/dist/index.js CHANGED
@@ -5,7 +5,9 @@ var STYLES = (
5
5
  :host { all: initial; }
6
6
  * { box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
7
7
 
8
- :root, .loupe {
8
+ /* Custom properties must live on :host \u2014 inside the Shadow DOM, :root matches
9
+ nothing and no element carries a .loupe class, so they'd never resolve. */
10
+ :host {
9
11
  --accent: #4a55d6;
10
12
  --pin: #ff5842;
11
13
  --ink: #16181f;
@@ -33,6 +35,17 @@ var STYLES = (
33
35
  font-family: ui-monospace, Menlo, monospace;
34
36
  }
35
37
 
38
+ /* region selection (during drag) + active-comment outline */
39
+ .selbox {
40
+ position: fixed; pointer-events: none; z-index: 2147483001; display: none;
41
+ border: 2px dashed var(--accent); background: rgba(74, 85, 214, 0.12); border-radius: 4px;
42
+ }
43
+ .region-box {
44
+ position: fixed; pointer-events: none; z-index: 2147483001; display: none;
45
+ border: 2px solid var(--pin); border-radius: 4px;
46
+ box-shadow: 0 0 0 2px rgba(255, 88, 66, .25), 0 4px 16px rgba(0,0,0,.25);
47
+ }
48
+
36
49
  /* pins */
37
50
  .pin {
38
51
  position: fixed; pointer-events: auto; z-index: 2147483002;
@@ -62,6 +75,7 @@ var STYLES = (
62
75
  display: flex; align-items: center; gap: 6px;
63
76
  }
64
77
  .toolbar button:hover { background: #2b2f3b; color: #fff; }
78
+ .toolbar button .ico { flex: none; display: block; }
65
79
  .toolbar button.on { background: var(--accent); color: #fff; }
66
80
  .toolbar .sep { width: 1px; height: 20px; background: #333846; margin: 0 2px; }
67
81
  .toolbar .brand { font-weight: 700; padding-left: 8px; padding-right: 4px; letter-spacing: -.01em; }
@@ -1916,6 +1930,50 @@ async function captureScreenshot(el2) {
1916
1930
  return void 0;
1917
1931
  }
1918
1932
  }
1933
+ async function captureRegionScreenshot(rect) {
1934
+ try {
1935
+ const scale = Math.min(window.devicePixelRatio || 1, 2);
1936
+ const full = await domToPng(document.body, {
1937
+ scale,
1938
+ backgroundColor: getComputedStyle(document.body).backgroundColor || "#ffffff",
1939
+ filter: (node) => {
1940
+ if (!(node instanceof Element)) return true;
1941
+ if (node.id === "loupe-root") return false;
1942
+ if (node.hasAttribute("data-loupe-redact")) return false;
1943
+ return true;
1944
+ }
1945
+ });
1946
+ const bodyRect = document.body.getBoundingClientRect();
1947
+ const redact = Array.from(document.querySelectorAll("[data-loupe-redact]")).map(
1948
+ (n) => n.getBoundingClientRect()
1949
+ );
1950
+ return await cropRegion(full, rect, bodyRect.left, bodyRect.top, scale, redact);
1951
+ } catch (err) {
1952
+ console.warn("[loupe] region capture failed", err);
1953
+ return void 0;
1954
+ }
1955
+ }
1956
+ function cropRegion(dataUrl, rect, ox, oy, scale, redact) {
1957
+ return new Promise((resolve, reject) => {
1958
+ const img = new Image();
1959
+ img.onload = () => {
1960
+ const sw = Math.max(1, Math.round(rect.w * scale));
1961
+ const sh = Math.max(1, Math.round(rect.h * scale));
1962
+ const canvas = document.createElement("canvas");
1963
+ canvas.width = sw;
1964
+ canvas.height = sh;
1965
+ const ctx = canvas.getContext("2d");
1966
+ ctx.drawImage(img, (rect.x - ox) * scale, (rect.y - oy) * scale, sw, sh, 0, 0, sw, sh);
1967
+ ctx.fillStyle = "#0f0f14";
1968
+ for (const r of redact) {
1969
+ ctx.fillRect((r.left - rect.x) * scale, (r.top - rect.y) * scale, r.width * scale, r.height * scale);
1970
+ }
1971
+ resolve(canvas.toDataURL("image/png"));
1972
+ };
1973
+ img.onerror = reject;
1974
+ img.src = dataUrl;
1975
+ });
1976
+ }
1919
1977
 
1920
1978
  // src/store.ts
1921
1979
  var LocalStorageAdapter = class {
@@ -1971,56 +2029,63 @@ var LocalStorageAdapter = class {
1971
2029
 
1972
2030
  // src/http-adapter.ts
1973
2031
  var HttpAdapter = class {
1974
- constructor(base, user, userHmac) {
2032
+ constructor(base, user, userHmac, extraHeaders, credentials) {
1975
2033
  this.base = base;
1976
2034
  this.user = user;
1977
2035
  this.userHmac = userHmac;
2036
+ this.extraHeaders = extraHeaders;
2037
+ this.credentials = credentials;
1978
2038
  this.base = base.replace(/\/$/, "");
1979
2039
  }
1980
2040
  headers() {
1981
2041
  const h = { "Content-Type": "application/json", "X-Loupe-User": this.user.id };
1982
2042
  if (this.userHmac) h["X-Loupe-Hmac"] = this.userHmac;
2043
+ if (this.extraHeaders) Object.assign(h, this.extraHeaders);
1983
2044
  return h;
1984
2045
  }
2046
+ /** Base fetch options shared by every request (credentials mode, if set). */
2047
+ opts(init2) {
2048
+ return this.credentials ? { credentials: this.credentials, ...init2 } : { ...init2 };
2049
+ }
1985
2050
  async list(projectKey, url) {
1986
2051
  const q = new URLSearchParams({ projectKey, url });
1987
- const res = await fetch(`${this.base}/v1/comments?${q}`, { headers: this.headers() });
2052
+ const res = await fetch(`${this.base}/v1/comments?${q}`, this.opts({ headers: this.headers() }));
1988
2053
  if (!res.ok) throw new Error(`list failed: ${res.status}`);
1989
2054
  return await res.json();
1990
2055
  }
1991
2056
  async save(comment) {
1992
2057
  if (comment.screenshot?.startsWith("data:")) {
1993
2058
  try {
1994
- const up = await fetch(`${this.base}/v1/blobs`, {
2059
+ const up = await fetch(`${this.base}/v1/blobs`, this.opts({
1995
2060
  method: "POST",
1996
2061
  headers: this.headers(),
1997
2062
  body: JSON.stringify({ projectKey: comment.projectKey, data: comment.screenshot })
1998
- });
2063
+ }));
1999
2064
  if (up.ok) comment = { ...comment, screenshot: (await up.json()).url };
2000
2065
  } catch {
2001
2066
  }
2002
2067
  }
2003
- const res = await fetch(`${this.base}/v1/comments`, {
2068
+ const res = await fetch(`${this.base}/v1/comments`, this.opts({
2004
2069
  method: "POST",
2005
2070
  headers: this.headers(),
2006
2071
  body: JSON.stringify(comment)
2007
- });
2072
+ }));
2008
2073
  if (!res.ok) throw new Error(`save failed: ${res.status}`);
2009
2074
  return await res.json();
2010
2075
  }
2011
2076
  async update(id, patch) {
2012
- const res = await fetch(`${this.base}/v1/comments/${encodeURIComponent(id)}`, {
2077
+ const res = await fetch(`${this.base}/v1/comments/${encodeURIComponent(id)}`, this.opts({
2013
2078
  method: "PATCH",
2014
2079
  headers: this.headers(),
2015
2080
  body: JSON.stringify(patch)
2016
- });
2081
+ }));
2017
2082
  if (!res.ok) throw new Error(`update failed: ${res.status}`);
2018
2083
  }
2019
2084
  async remove(id) {
2020
- const res = await fetch(`${this.base}/v1/comments/${encodeURIComponent(id)}`, {
2085
+ const res = await fetch(`${this.base}/v1/comments/${encodeURIComponent(id)}`, this.opts({
2021
2086
  method: "DELETE",
2022
2087
  headers: this.headers()
2023
- });
2088
+ }));
2024
2089
  if (!res.ok && res.status !== 404) throw new Error(`remove failed: ${res.status}`);
2025
2090
  }
2026
2091
  };
@@ -2039,14 +2104,16 @@ var LoupeApp = class {
2039
2104
  this.resolved = /* @__PURE__ */ new Map();
2040
2105
  /** comment.id → pin element. */
2041
2106
  this.pins = /* @__PURE__ */ new Map();
2042
- this.inspecting = false;
2043
- this.target = null;
2107
+ this.mode = "off";
2044
2108
  this.targetOffset = { x: 0.5, y: 0.5 };
2045
- this.activeId = null;
2109
+ this.pending = null;
2110
+ this.dragStart = null;
2111
+ /** region comment whose outline is currently highlighted (tracks scroll). */
2112
+ this.activeRegionId = null;
2046
2113
  this.raf = 0;
2047
2114
  // ---- inspector ------------------------------------------------------------
2048
2115
  this.onMove = (e) => {
2049
- if (!this.inspecting) return;
2116
+ if (this.mode !== "inspect") return;
2050
2117
  const target = this.pick(e.clientX, e.clientY);
2051
2118
  if (!target) {
2052
2119
  this.hl.style.display = "none";
@@ -2063,7 +2130,7 @@ var LoupeApp = class {
2063
2130
  this.hl.firstChild.textContent = target.tagName.toLowerCase() + (target.id ? "#" + target.id : "");
2064
2131
  };
2065
2132
  this.onClick = (e) => {
2066
- if (!this.inspecting) return;
2133
+ if (this.mode !== "inspect") return;
2067
2134
  const target = this.pick(e.clientX, e.clientY);
2068
2135
  if (!target) return;
2069
2136
  e.preventDefault();
@@ -2073,16 +2140,50 @@ var LoupeApp = class {
2073
2140
  x: r.width ? clamp((e.clientX - r.left) / r.width) : 0.5,
2074
2141
  y: r.height ? clamp((e.clientY - r.top) / r.height) : 0.5
2075
2142
  };
2076
- this.target = target;
2077
- this.setInspecting(false);
2078
- this.openComposer(target, e.clientX, e.clientY);
2143
+ this.setMode("off");
2144
+ this.openComposer({ kind: "element", element: target }, e.clientX, e.clientY);
2079
2145
  };
2080
2146
  this.onKey = (e) => {
2081
2147
  if (e.key === "Escape") {
2082
- this.setInspecting(false);
2148
+ this.cancelDrag();
2149
+ this.setMode("off");
2083
2150
  this.closeComposer();
2084
2151
  }
2085
2152
  };
2153
+ // ---- region ("free-size screenshot") selection ----------------------------
2154
+ this.onRegionDown = (e) => {
2155
+ if (this.mode !== "region" || e.button !== 0) return;
2156
+ e.preventDefault();
2157
+ e.stopPropagation();
2158
+ this.dragStart = { x: e.clientX, y: e.clientY };
2159
+ document.addEventListener("mousemove", this.onRegionMove, true);
2160
+ document.addEventListener("mouseup", this.onRegionUp, true);
2161
+ this.drawSelection(e.clientX, e.clientY);
2162
+ };
2163
+ this.onRegionMove = (e) => {
2164
+ if (!this.dragStart) return;
2165
+ e.preventDefault();
2166
+ this.drawSelection(e.clientX, e.clientY);
2167
+ };
2168
+ this.onRegionUp = (e) => {
2169
+ if (!this.dragStart) return;
2170
+ e.preventDefault();
2171
+ e.stopPropagation();
2172
+ const start = this.dragStart;
2173
+ this.cancelDrag();
2174
+ const vp = {
2175
+ x: Math.min(start.x, e.clientX),
2176
+ y: Math.min(start.y, e.clientY),
2177
+ w: Math.abs(e.clientX - start.x),
2178
+ h: Math.abs(e.clientY - start.y)
2179
+ };
2180
+ if (vp.w < 8 || vp.h < 8) {
2181
+ this.selbox.style.display = "none";
2182
+ return;
2183
+ }
2184
+ this.setMode("off");
2185
+ this.finishRegion(vp);
2186
+ };
2086
2187
  /** Reposition every pin, re-resolving anchors whose element has gone. */
2087
2188
  this.position = () => {
2088
2189
  for (const c of this.comments) {
@@ -2094,6 +2195,18 @@ var LoupeApp = class {
2094
2195
  elx = r?.element ?? null;
2095
2196
  this.resolved.set(c.id, elx);
2096
2197
  }
2198
+ if (c.kind === "region") {
2199
+ const box = this.regionRect(c, elx);
2200
+ if (box) {
2201
+ const onScreen = box.x + box.w > 0 && box.x < window.innerWidth && box.y + box.h > 0 && box.y < window.innerHeight;
2202
+ Object.assign(pin.style, { left: box.x + "px", top: box.y + "px", display: onScreen ? "grid" : "none" });
2203
+ pin.classList.toggle("detached", !elx && !c.region?.rel);
2204
+ } else {
2205
+ pin.style.display = "none";
2206
+ pin.classList.add("detached");
2207
+ }
2208
+ continue;
2209
+ }
2097
2210
  if (elx) {
2098
2211
  const rect = elx.getBoundingClientRect();
2099
2212
  const px = rect.left + c.offset.x * rect.width;
@@ -2106,9 +2219,22 @@ var LoupeApp = class {
2106
2219
  pin.classList.add("detached");
2107
2220
  }
2108
2221
  }
2222
+ if (this.activeRegionId) {
2223
+ const c = this.comments.find((x) => x.id === this.activeRegionId);
2224
+ const box = c && this.regionRect(c, this.resolved.get(c.id) ?? null);
2225
+ if (box) {
2226
+ Object.assign(this.regionBox.style, {
2227
+ display: "block",
2228
+ left: box.x + "px",
2229
+ top: box.y + "px",
2230
+ width: box.w + "px",
2231
+ height: box.h + "px"
2232
+ });
2233
+ }
2234
+ }
2109
2235
  };
2110
2236
  this.cfg = cfg;
2111
- this.store = cfg.apiBase ? new HttpAdapter(cfg.apiBase, cfg.user, cfg.userHmac) : new LocalStorageAdapter();
2237
+ this.store = cfg.apiBase ? new HttpAdapter(cfg.apiBase, cfg.user, cfg.userHmac, cfg.headers, cfg.credentials) : new LocalStorageAdapter();
2112
2238
  }
2113
2239
  get url() {
2114
2240
  return location.pathname + location.search;
@@ -2119,7 +2245,7 @@ var LoupeApp = class {
2119
2245
  this.renderPins();
2120
2246
  this.renderPanel();
2121
2247
  this.observe();
2122
- if (this.cfg.autoOpen) this.setInspecting(true);
2248
+ if (this.cfg.autoOpen) this.setMode("inspect");
2123
2249
  }
2124
2250
  // ---- DOM construction -----------------------------------------------------
2125
2251
  buildDom() {
@@ -2133,7 +2259,9 @@ var LoupeApp = class {
2133
2259
  this.overlay = el("div", "overlay");
2134
2260
  this.hl = el("div", "hl");
2135
2261
  this.hl.appendChild(el("span", "tip"));
2136
- this.overlay.appendChild(this.hl);
2262
+ this.selbox = el("div", "selbox");
2263
+ this.regionBox = el("div", "region-box");
2264
+ this.overlay.append(this.hl, this.selbox, this.regionBox);
2137
2265
  this.shadow.appendChild(this.overlay);
2138
2266
  this.composer = el("div", "composer");
2139
2267
  this.shadow.appendChild(this.composer);
@@ -2147,14 +2275,55 @@ var LoupeApp = class {
2147
2275
  const brand = el("span", "brand", "\u25CE Loupe");
2148
2276
  const inspectBtn = el("button", "", "\u271B Inspect & comment");
2149
2277
  inspectBtn.dataset.role = "inspect";
2150
- inspectBtn.onclick = () => this.setInspecting(!this.inspecting);
2278
+ inspectBtn.onclick = () => this.setMode(this.mode === "inspect" ? "off" : "inspect");
2279
+ const regionBtn = el("button");
2280
+ regionBtn.innerHTML = `${REGION_ICON}<span>Region shot</span>`;
2281
+ regionBtn.dataset.role = "region";
2282
+ regionBtn.title = "Drag a free-size box, screenshot it, and comment";
2283
+ regionBtn.onclick = () => this.setMode(this.mode === "region" ? "off" : "region");
2151
2284
  const listBtn = el("button", "", "\u2630 Comments");
2152
2285
  this.countEl = el("span", "count", "0");
2153
2286
  listBtn.appendChild(this.countEl);
2154
2287
  listBtn.onclick = () => this.togglePanel();
2155
- bar.append(brand, sep(), inspectBtn, listBtn);
2288
+ bar.append(brand, sep(), inspectBtn, regionBtn, listBtn);
2156
2289
  return bar;
2157
2290
  }
2291
+ drawSelection(curX, curY) {
2292
+ const s = this.dragStart;
2293
+ Object.assign(this.selbox.style, {
2294
+ display: "block",
2295
+ left: Math.min(s.x, curX) + "px",
2296
+ top: Math.min(s.y, curY) + "px",
2297
+ width: Math.abs(curX - s.x) + "px",
2298
+ height: Math.abs(curY - s.y) + "px"
2299
+ });
2300
+ }
2301
+ cancelDrag() {
2302
+ this.dragStart = null;
2303
+ document.removeEventListener("mousemove", this.onRegionMove, true);
2304
+ document.removeEventListener("mouseup", this.onRegionUp, true);
2305
+ }
2306
+ /** Capture the selected viewport rect, then open the composer for a region comment. */
2307
+ async finishRegion(vp) {
2308
+ const centerEl = this.pick(vp.x + vp.w / 2, vp.y + vp.h / 2);
2309
+ let rel;
2310
+ if (centerEl) {
2311
+ const er = centerEl.getBoundingClientRect();
2312
+ if (er.width > 0 && er.height > 0) {
2313
+ rel = {
2314
+ fx: (vp.x - er.left) / er.width,
2315
+ fy: (vp.y - er.top) / er.height,
2316
+ fw: vp.w / er.width,
2317
+ fh: vp.h / er.height
2318
+ };
2319
+ }
2320
+ }
2321
+ const capture = this.cfg.captureRegion ?? captureRegionScreenshot;
2322
+ const screenshot = await capture(vp);
2323
+ this.selbox.style.display = "none";
2324
+ const region = { x: vp.x + window.scrollX, y: vp.y + window.scrollY, w: vp.w, h: vp.h, rel };
2325
+ this.openComposer({ kind: "region", region, element: centerEl, screenshot }, vp.x + vp.w, vp.y);
2326
+ }
2158
2327
  /** elementFromPoint, ignoring our own UI. */
2159
2328
  pick(x, y) {
2160
2329
  const hitHl = this.hl.style.display;
@@ -2165,34 +2334,45 @@ var LoupeApp = class {
2165
2334
  if (elAt.id === "loupe-root" || elAt.closest?.("#loupe-root")) return null;
2166
2335
  return elAt;
2167
2336
  }
2168
- setInspecting(on) {
2169
- this.inspecting = on;
2337
+ setMode(mode) {
2338
+ this.mode = mode;
2170
2339
  this.hl.style.display = "none";
2171
- document.body.style.cursor = on ? "crosshair" : "";
2172
- const btn = this.toolbar.querySelector('[data-role="inspect"]');
2173
- btn.classList.toggle("on", on);
2174
- if (on) {
2340
+ this.selbox.style.display = "none";
2341
+ this.cancelDrag();
2342
+ document.body.style.cursor = mode === "off" ? "" : "crosshair";
2343
+ this.toolbar.querySelector('[data-role="inspect"]')?.classList.toggle("on", mode === "inspect");
2344
+ this.toolbar.querySelector('[data-role="region"]')?.classList.toggle("on", mode === "region");
2345
+ document.removeEventListener("mousemove", this.onMove, true);
2346
+ document.removeEventListener("click", this.onClick, true);
2347
+ document.removeEventListener("mousedown", this.onRegionDown, true);
2348
+ if (mode === "off") return;
2349
+ document.addEventListener("keydown", this.onKey, true);
2350
+ this.closeComposer();
2351
+ if (mode === "inspect") {
2175
2352
  document.addEventListener("mousemove", this.onMove, true);
2176
2353
  document.addEventListener("click", this.onClick, true);
2177
- document.addEventListener("keydown", this.onKey, true);
2178
- this.closeComposer();
2179
- } else {
2180
- document.removeEventListener("mousemove", this.onMove, true);
2181
- document.removeEventListener("click", this.onClick, true);
2354
+ } else if (mode === "region") {
2355
+ document.addEventListener("mousedown", this.onRegionDown, true);
2182
2356
  }
2183
2357
  }
2184
2358
  // ---- composer -------------------------------------------------------------
2185
2359
  openComposer(target, x, y) {
2360
+ this.pending = target;
2186
2361
  const c = this.composer;
2187
2362
  c.innerHTML = "";
2188
- const label = el("div", "target", describe(target));
2363
+ const label = el(
2364
+ "div",
2365
+ "target",
2366
+ target.kind === "element" ? describe(target.element) : `Region \xB7 ${Math.round(target.region.w)}\xD7${Math.round(target.region.h)} px`
2367
+ );
2189
2368
  const ta = el("textarea");
2190
- ta.placeholder = "What should change here?";
2369
+ ta.placeholder = target.kind === "region" ? "What's the issue in this area?" : "What should change here?";
2191
2370
  const row = el("div", "row");
2192
2371
  const chk = el("label", "chk");
2193
2372
  const box = document.createElement("input");
2194
2373
  box.type = "checkbox";
2195
- box.checked = true;
2374
+ box.checked = target.kind === "region" ? !!target.screenshot : true;
2375
+ if (target.kind === "region" && !target.screenshot) box.disabled = true;
2196
2376
  chk.append(box, document.createTextNode("Attach screenshot"));
2197
2377
  const btns = el("div", "btns");
2198
2378
  const cancel = el("button", "ghost", "Cancel");
@@ -2214,7 +2394,7 @@ var LoupeApp = class {
2214
2394
  }
2215
2395
  closeComposer() {
2216
2396
  this.composer.style.display = "none";
2217
- this.target = null;
2397
+ this.pending = null;
2218
2398
  }
2219
2399
  async submit(target, body, withShot) {
2220
2400
  if (!body) return;
@@ -2223,8 +2403,30 @@ var LoupeApp = class {
2223
2403
  saveBtn.disabled = true;
2224
2404
  saveBtn.textContent = "Saving\u2026";
2225
2405
  }
2226
- const capture = this.cfg.captureScreenshot ?? captureScreenshot;
2227
- const screenshot = withShot ? await capture(target) : void 0;
2406
+ let anchor, context, offset;
2407
+ let screenshot;
2408
+ let region;
2409
+ let anchoredEl = null;
2410
+ if (target.kind === "element") {
2411
+ const capture = this.cfg.captureScreenshot ?? captureScreenshot;
2412
+ screenshot = withShot ? await capture(target.element) : void 0;
2413
+ anchor = captureAnchor(target.element);
2414
+ context = captureElementContext(target.element);
2415
+ offset = this.targetOffset;
2416
+ anchoredEl = target.element;
2417
+ } else {
2418
+ screenshot = withShot ? target.screenshot : void 0;
2419
+ region = target.region;
2420
+ offset = { x: 0, y: 0 };
2421
+ if (target.element) {
2422
+ anchor = captureAnchor(target.element);
2423
+ context = captureElementContext(target.element);
2424
+ anchoredEl = target.element;
2425
+ } else {
2426
+ anchor = regionAnchor(region);
2427
+ context = { html: regionNote(region), styles: {} };
2428
+ }
2429
+ }
2228
2430
  const comment = {
2229
2431
  id: uid2(),
2230
2432
  projectKey: this.cfg.projectKey,
@@ -2232,15 +2434,17 @@ var LoupeApp = class {
2232
2434
  author: this.cfg.user,
2233
2435
  body,
2234
2436
  status: "open",
2235
- anchor: captureAnchor(target),
2236
- context: captureElementContext(target),
2237
- offset: this.targetOffset,
2437
+ kind: target.kind,
2438
+ anchor,
2439
+ context,
2440
+ offset,
2441
+ region,
2238
2442
  screenshot,
2239
2443
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
2240
2444
  };
2241
2445
  await this.store.save(comment);
2242
2446
  this.comments.push(comment);
2243
- this.resolved.set(comment.id, target);
2447
+ this.resolved.set(comment.id, anchoredEl);
2244
2448
  this.closeComposer();
2245
2449
  this.renderPins();
2246
2450
  this.renderPanel();
@@ -2271,6 +2475,22 @@ var LoupeApp = class {
2271
2475
  this.countEl.textContent = String(this.comments.length);
2272
2476
  this.position();
2273
2477
  }
2478
+ /**
2479
+ * The current viewport rect for a region comment. Prefers the element-relative
2480
+ * fractions (so it tracks reflow across viewports); falls back to the stored
2481
+ * document coordinates minus scroll. Returns null if neither is available.
2482
+ */
2483
+ regionRect(c, elx) {
2484
+ const rel = c.region?.rel;
2485
+ if (elx && rel) {
2486
+ const r = elx.getBoundingClientRect();
2487
+ 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 };
2488
+ }
2489
+ if (c.region) {
2490
+ return { x: c.region.x - window.scrollX, y: c.region.y - window.scrollY, w: c.region.w, h: c.region.h };
2491
+ }
2492
+ return null;
2493
+ }
2274
2494
  observe() {
2275
2495
  const reposition = () => {
2276
2496
  cancelAnimationFrame(this.raf);
@@ -2387,7 +2607,23 @@ var LoupeApp = class {
2387
2607
  }
2388
2608
  }
2389
2609
  flash(id) {
2610
+ const c = this.comments.find((x) => x.id === id);
2390
2611
  const pin = this.pins.get(id);
2612
+ if (c?.kind === "region" && c.region) {
2613
+ for (const p of this.pins.values()) p.classList.remove("active");
2614
+ pin?.classList.add("active");
2615
+ this.activeRegionId = id;
2616
+ window.clearTimeout(this.regionTimer);
2617
+ this.regionTimer = window.setTimeout(() => {
2618
+ this.activeRegionId = null;
2619
+ this.regionBox.style.display = "none";
2620
+ }, 2400);
2621
+ const elx2 = this.resolved.get(id);
2622
+ if (elx2) elx2.scrollIntoView({ behavior: "smooth", block: "center" });
2623
+ else window.scrollTo({ top: Math.max(0, c.region.y - 120), left: Math.max(0, c.region.x - 120), behavior: "smooth" });
2624
+ this.position();
2625
+ return;
2626
+ }
2391
2627
  if (!pin || pin.classList.contains("detached")) return;
2392
2628
  for (const p of this.pins.values()) p.classList.remove("active");
2393
2629
  pin.classList.add("active");
@@ -2395,9 +2631,10 @@ var LoupeApp = class {
2395
2631
  if (elx) elx.scrollIntoView({ behavior: "smooth", block: "center" });
2396
2632
  }
2397
2633
  destroy() {
2398
- this.setInspecting(false);
2634
+ this.setMode("off");
2399
2635
  this.mo?.disconnect();
2400
2636
  if (this.tick) clearInterval(this.tick);
2637
+ window.clearTimeout(this.regionTimer);
2401
2638
  document.removeEventListener("keydown", this.onKey, true);
2402
2639
  this.root?.remove();
2403
2640
  }
@@ -2414,6 +2651,23 @@ function sep() {
2414
2651
  function clamp(n) {
2415
2652
  return Math.max(0, Math.min(1, n));
2416
2653
  }
2654
+ 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>`;
2655
+ function regionAnchor(region) {
2656
+ return {
2657
+ tag: "region",
2658
+ cssPath: `region ${Math.round(region.w)}\xD7${Math.round(region.h)}`,
2659
+ xpath: "",
2660
+ testid: null,
2661
+ text: "",
2662
+ attrs: {},
2663
+ nthOfType: 1,
2664
+ rect: { x: Math.round(region.x), y: Math.round(region.y), w: Math.round(region.w), h: Math.round(region.h) },
2665
+ viewport: { w: window.innerWidth, h: window.innerHeight }
2666
+ };
2667
+ }
2668
+ function regionNote(region) {
2669
+ 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. -->`;
2670
+ }
2417
2671
  function describe(elx) {
2418
2672
  const testid = elx.getAttribute("data-testid") || elx.getAttribute("data-test");
2419
2673
  const tag = elx.tagName.toLowerCase();