@loupekit/sdk 0.4.3 → 0.5.0-next.16
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/README.md +6 -2
- package/dist/index.global.js +262 -16
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +262 -16
- package/dist/index.js.map +1 -1
- package/package.json +26 -7
package/README.md
CHANGED
|
@@ -19,6 +19,7 @@ comments re-anchor across redeploys and flow to Claude Code as an actionable bac
|
|
|
19
19
|
|
|
20
20
|
<p>
|
|
21
21
|
<a href="https://mohamed-ashraf-elsaed.github.io/loupe/"><b>Website</b></a> ·
|
|
22
|
+
<a href="https://mohamed-ashraf-elsaed.github.io/loupe/guide/"><b>Docs</b></a> ·
|
|
22
23
|
<a href="https://github.com/mohamed-ashraf-elsaed/loupe"><b>GitHub</b></a> ·
|
|
23
24
|
<a href="https://github.com/mohamed-ashraf-elsaed/loupe/blob/main/CHANGELOG.md"><b>Changelog</b></a> ·
|
|
24
25
|
<a href="https://www.npmjs.com/package/@loupekit/mcp"><b>MCP server</b></a>
|
|
@@ -56,7 +57,9 @@ the moment of the comment, so the feedback stays actionable even after the UI ch
|
|
|
56
57
|
| | |
|
|
57
58
|
| --- | --- |
|
|
58
59
|
| 🎯 **Click-to-comment inspector** | Hover-highlight any element, click to pin a comment. |
|
|
60
|
+
| 💬 **Free comments** | Drop a page-level note anywhere with the **Note** mode — no element, no screenshot. |
|
|
59
61
|
| ▭ **Free-region screenshots** | Drag a free-size box, screenshot exactly that area, comment on it. The region anchors to the element under its center, so it tracks responsive reflow and scrolling. |
|
|
62
|
+
| 🧲 **Draggable toolbar** | Drag the `◎` logo anywhere; the position persists and the bar expands edge-aware (vertical on a side, horizontal on top/bottom). |
|
|
60
63
|
| 🔁 **Redeploy-surviving re-anchoring** | A multi-signal fingerprint (stable id/testid, CSS path, XPath, text, attributes, position) re-locates the element on the current page; if it can't, the pin **detaches** instead of pointing at the wrong thing. |
|
|
61
64
|
| 📸 **Screenshot capture** | `[data-loupe-redact]` regions are painted over **before any pixels leave the browser**. |
|
|
62
65
|
| 🧩 **Shadow-DOM isolation** | The widget's CSS never leaks into your page and vice-versa. |
|
|
@@ -86,8 +89,9 @@ init({
|
|
|
86
89
|
});
|
|
87
90
|
```
|
|
88
91
|
|
|
89
|
-
A floating toolbar appears with **Inspect & comment**, **
|
|
90
|
-
|
|
92
|
+
A floating toolbar appears with **Inspect & comment**, **Note** (a free page-level comment),
|
|
93
|
+
**Region shot**, and **Comments**. Drag the `◎` logo to move the bar anywhere; click it to
|
|
94
|
+
collapse. Call `destroy()` to tear it down. `init()` is idempotent — safe to call more than once.
|
|
91
95
|
|
|
92
96
|
### Offline mode (no backend)
|
|
93
97
|
|
package/dist/index.global.js
CHANGED
|
@@ -86,6 +86,8 @@ var Loupe = (() => {
|
|
|
86
86
|
.pin:hover { transform: translate(-4px, -4px) scale(1.12); }
|
|
87
87
|
.pin.detached { background: #9aa0af; }
|
|
88
88
|
.pin.done { background: #10935a; }
|
|
89
|
+
.pin.free { background: var(--accent); border-radius: 50% 50% 2px 50%; }
|
|
90
|
+
.pin.free.done { background: #10935a; }
|
|
89
91
|
.pin.active { outline: 3px solid rgba(74,85,214,.4); }
|
|
90
92
|
|
|
91
93
|
/* toolbar */
|
|
@@ -112,11 +114,24 @@ var Loupe = (() => {
|
|
|
112
114
|
.toolbar button.on { background: var(--accent); color: #fff; }
|
|
113
115
|
.toolbar .sep { width: 1px; height: 20px; background: #333846; margin: 0 2px; }
|
|
114
116
|
.toolbar .brand {
|
|
115
|
-
font-weight: 700; letter-spacing: -.01em; cursor:
|
|
117
|
+
font-weight: 700; letter-spacing: -.01em; cursor: grab; user-select: none; touch-action: none;
|
|
116
118
|
}
|
|
119
|
+
.toolbar.dragging { transition: none; box-shadow: 0 12px 40px rgba(0,0,0,.5); }
|
|
120
|
+
.toolbar.dragging .brand { cursor: grabbing; }
|
|
117
121
|
/* collapsed \u2192 show only the brand/logo; click it again to expand */
|
|
118
122
|
.toolbar.collapsed { gap: 0; }
|
|
119
123
|
.toolbar.collapsed > *:not(.brand) { display: none; }
|
|
124
|
+
|
|
125
|
+
/* free-move: the bar is fixed-positioned by JS and expands *inward* from its
|
|
126
|
+
docked edge \u2014 vertical (a column) against a left/right edge, horizontal
|
|
127
|
+
otherwise \u2014 so it's always fully on-screen. */
|
|
128
|
+
.toolbar.floating { transform: none; }
|
|
129
|
+
.toolbar.orient-h.flow-rev { flex-direction: row-reverse; }
|
|
130
|
+
.toolbar.orient-v { flex-direction: column; align-items: stretch; }
|
|
131
|
+
.toolbar.orient-v.flow-rev { flex-direction: column-reverse; }
|
|
132
|
+
.toolbar.orient-v button, .toolbar.orient-v .brand { justify-content: flex-start; }
|
|
133
|
+
.toolbar.orient-v .sep { width: 22px; height: 1px; margin: 2px auto; }
|
|
134
|
+
.toolbar.orient-v .count { margin-left: auto; }
|
|
120
135
|
.toolbar .count {
|
|
121
136
|
background: var(--pin); color: #fff; font-size: 11px; font-weight: 700;
|
|
122
137
|
border-radius: 999px; padding: 1px 7px; margin-left: 2px;
|
|
@@ -2196,6 +2211,12 @@ var Loupe = (() => {
|
|
|
2196
2211
|
this.dragStart = null;
|
|
2197
2212
|
/** region comment whose outline is currently highlighted (tracks scroll). */
|
|
2198
2213
|
this.activeRegionId = null;
|
|
2214
|
+
/** Free-move toolbar position as viewport fractions, or null → default (bottom-center). */
|
|
2215
|
+
this.barPos = null;
|
|
2216
|
+
/** In-flight drag of the toolbar by its ◎ handle. */
|
|
2217
|
+
this.barDrag = null;
|
|
2218
|
+
/** True right after a drag so the trailing click doesn't also toggle collapse. */
|
|
2219
|
+
this.justDragged = false;
|
|
2199
2220
|
this.raf = 0;
|
|
2200
2221
|
// ---- inspector ------------------------------------------------------------
|
|
2201
2222
|
this.onMove = (e) => {
|
|
@@ -2270,11 +2291,36 @@ var Loupe = (() => {
|
|
|
2270
2291
|
this.setMode("off");
|
|
2271
2292
|
this.finishRegion(vp);
|
|
2272
2293
|
};
|
|
2294
|
+
// ---- free note (drop a comment anywhere, no element / no screenshot) -------
|
|
2295
|
+
this.onFreeClick = (e) => {
|
|
2296
|
+
if (this.mode !== "free") return;
|
|
2297
|
+
const t = e.target;
|
|
2298
|
+
if (t && (t.id === "loupe-root" || t.closest?.("#loupe-root"))) return;
|
|
2299
|
+
e.preventDefault();
|
|
2300
|
+
e.stopPropagation();
|
|
2301
|
+
const docX = e.clientX + window.scrollX;
|
|
2302
|
+
const docY = e.clientY + window.scrollY;
|
|
2303
|
+
const docW = Math.max(1, document.documentElement.scrollWidth);
|
|
2304
|
+
const docH = Math.max(1, document.documentElement.scrollHeight);
|
|
2305
|
+
const offset = { x: clamp(docX / docW), y: clamp(docY / docH) };
|
|
2306
|
+
this.setMode("off");
|
|
2307
|
+
this.openComposer({ kind: "free", offset, point: { x: docX, y: docY } }, e.clientX, e.clientY);
|
|
2308
|
+
};
|
|
2273
2309
|
/** Reposition every pin, re-resolving anchors whose element has gone. */
|
|
2274
2310
|
this.position = () => {
|
|
2275
2311
|
for (const c of this.comments) {
|
|
2276
2312
|
const pin = this.pins.get(c.id);
|
|
2277
2313
|
if (!pin) continue;
|
|
2314
|
+
if (c.kind === "free") {
|
|
2315
|
+
const docW = Math.max(1, document.documentElement.scrollWidth);
|
|
2316
|
+
const docH = Math.max(1, document.documentElement.scrollHeight);
|
|
2317
|
+
const px = c.offset.x * docW - window.scrollX;
|
|
2318
|
+
const py = c.offset.y * docH - window.scrollY;
|
|
2319
|
+
const onScreen = px > -24 && px < window.innerWidth + 24 && py > -24 && py < window.innerHeight + 24;
|
|
2320
|
+
Object.assign(pin.style, { left: px + "px", top: py + "px", display: onScreen ? "grid" : "none" });
|
|
2321
|
+
pin.classList.remove("detached");
|
|
2322
|
+
continue;
|
|
2323
|
+
}
|
|
2278
2324
|
let elx = this.resolved.get(c.id) ?? null;
|
|
2279
2325
|
if (!elx || !elx.isConnected) {
|
|
2280
2326
|
const r = resolveAnchor(c.anchor);
|
|
@@ -2319,6 +2365,58 @@ var Loupe = (() => {
|
|
|
2319
2365
|
}
|
|
2320
2366
|
}
|
|
2321
2367
|
};
|
|
2368
|
+
// ---- draggable, edge-aware floating toolbar --------------------------------
|
|
2369
|
+
this.onResizeBar = () => this.layoutBar();
|
|
2370
|
+
this.onBarPointerDown = (e) => {
|
|
2371
|
+
if (typeof e.button === "number" && e.button !== 0) return;
|
|
2372
|
+
const brand = this.brandEl();
|
|
2373
|
+
const r = brand.getBoundingClientRect();
|
|
2374
|
+
this.barDrag = { px: e.clientX, py: e.clientY, ix: r.left, iy: r.top };
|
|
2375
|
+
this.justDragged = false;
|
|
2376
|
+
try {
|
|
2377
|
+
brand.setPointerCapture(e.pointerId);
|
|
2378
|
+
} catch {
|
|
2379
|
+
}
|
|
2380
|
+
brand.addEventListener("pointermove", this.onBarPointerMove);
|
|
2381
|
+
brand.addEventListener("pointerup", this.onBarPointerUp);
|
|
2382
|
+
brand.addEventListener("pointercancel", this.onBarPointerUp);
|
|
2383
|
+
};
|
|
2384
|
+
this.onBarPointerMove = (e) => {
|
|
2385
|
+
if (!this.barDrag) return;
|
|
2386
|
+
const dx = e.clientX - this.barDrag.px;
|
|
2387
|
+
const dy = e.clientY - this.barDrag.py;
|
|
2388
|
+
if (!this.justDragged && Math.hypot(dx, dy) < 5) return;
|
|
2389
|
+
this.justDragged = true;
|
|
2390
|
+
e.preventDefault();
|
|
2391
|
+
const vw = window.innerWidth, vh = window.innerHeight;
|
|
2392
|
+
const bar = this.toolbar;
|
|
2393
|
+
const brand = this.brandEl();
|
|
2394
|
+
const iw = brand.offsetWidth || 44, ih = brand.offsetHeight || 44;
|
|
2395
|
+
const ix = clampPx(this.barDrag.ix + dx, 6, vw - iw - 6);
|
|
2396
|
+
const iy = clampPx(this.barDrag.iy + dy, 6, vh - ih - 6);
|
|
2397
|
+
bar.classList.add("floating", "dragging");
|
|
2398
|
+
bar.classList.remove("orient-v", "flow-rev");
|
|
2399
|
+
bar.classList.add("orient-h");
|
|
2400
|
+
Object.assign(bar.style, { transform: "none", left: ix + "px", top: iy + "px", right: "auto", bottom: "auto" });
|
|
2401
|
+
this.barPos = { fx: ix / vw, fy: iy / vh };
|
|
2402
|
+
};
|
|
2403
|
+
this.onBarPointerUp = (e) => {
|
|
2404
|
+
const brand = this.brandEl();
|
|
2405
|
+
brand.removeEventListener("pointermove", this.onBarPointerMove);
|
|
2406
|
+
brand.removeEventListener("pointerup", this.onBarPointerUp);
|
|
2407
|
+
brand.removeEventListener("pointercancel", this.onBarPointerUp);
|
|
2408
|
+
try {
|
|
2409
|
+
brand.releasePointerCapture(e.pointerId);
|
|
2410
|
+
} catch {
|
|
2411
|
+
}
|
|
2412
|
+
const dragged = this.justDragged;
|
|
2413
|
+
this.barDrag = null;
|
|
2414
|
+
this.toolbar.classList.remove("dragging");
|
|
2415
|
+
if (dragged) {
|
|
2416
|
+
this.saveBarPos();
|
|
2417
|
+
this.layoutBar();
|
|
2418
|
+
}
|
|
2419
|
+
};
|
|
2322
2420
|
this.cfg = cfg;
|
|
2323
2421
|
this.store = cfg.apiBase ? new HttpAdapter(cfg.apiBase, cfg.user, cfg.userHmac, cfg.headers, cfg.credentials) : new LocalStorageAdapter();
|
|
2324
2422
|
}
|
|
@@ -2326,7 +2424,9 @@ var Loupe = (() => {
|
|
|
2326
2424
|
return location.pathname + location.search;
|
|
2327
2425
|
}
|
|
2328
2426
|
async start() {
|
|
2427
|
+
this.loadBarPos();
|
|
2329
2428
|
this.buildDom();
|
|
2429
|
+
this.layoutBar();
|
|
2330
2430
|
this.lastUrl = this.url;
|
|
2331
2431
|
this.comments = await this.store.list(this.cfg.projectKey, this.url);
|
|
2332
2432
|
this.renderPins();
|
|
@@ -2391,11 +2491,21 @@ var Loupe = (() => {
|
|
|
2391
2491
|
const bar = el("div", "toolbar");
|
|
2392
2492
|
const brand = el("span", "brand");
|
|
2393
2493
|
brand.innerHTML = `<span class="ico">\u25CE</span><span class="label">Loupe</span>`;
|
|
2394
|
-
brand.title = "
|
|
2494
|
+
brand.title = "Drag to move \xB7 click to collapse / expand";
|
|
2395
2495
|
brand.setAttribute("role", "button");
|
|
2396
|
-
brand.onclick = () =>
|
|
2496
|
+
brand.onclick = () => {
|
|
2497
|
+
if (this.justDragged) {
|
|
2498
|
+
this.justDragged = false;
|
|
2499
|
+
return;
|
|
2500
|
+
}
|
|
2501
|
+
this.toggleCollapsed();
|
|
2502
|
+
};
|
|
2503
|
+
brand.addEventListener("pointerdown", this.onBarPointerDown);
|
|
2397
2504
|
const inspectBtn = this.toolBtn("\u271B", "Inspect & comment", "inspect");
|
|
2398
2505
|
inspectBtn.onclick = () => this.setMode(this.mode === "inspect" ? "off" : "inspect");
|
|
2506
|
+
const freeBtn = this.toolBtn(NOTE_ICON, "Note", "free");
|
|
2507
|
+
freeBtn.title = "Drop a note anywhere on the page \u2014 no element, no screenshot";
|
|
2508
|
+
freeBtn.onclick = () => this.setMode(this.mode === "free" ? "off" : "free");
|
|
2399
2509
|
const regionBtn = this.toolBtn(REGION_ICON, "Region shot", "region");
|
|
2400
2510
|
regionBtn.title = "Drag a free-size box, screenshot it, and comment";
|
|
2401
2511
|
regionBtn.onclick = () => this.setMode(this.mode === "region" ? "off" : "region");
|
|
@@ -2403,7 +2513,7 @@ var Loupe = (() => {
|
|
|
2403
2513
|
this.countEl = el("span", "count", "0");
|
|
2404
2514
|
listBtn.appendChild(this.countEl);
|
|
2405
2515
|
listBtn.onclick = () => this.togglePanel();
|
|
2406
|
-
bar.append(brand, sep(), inspectBtn, regionBtn, listBtn);
|
|
2516
|
+
bar.append(brand, sep(), inspectBtn, freeBtn, regionBtn, listBtn);
|
|
2407
2517
|
return bar;
|
|
2408
2518
|
}
|
|
2409
2519
|
/** A toolbar button with a uniform icon + label layout. `icon` may be an SVG string. */
|
|
@@ -2472,9 +2582,11 @@ var Loupe = (() => {
|
|
|
2472
2582
|
this.cancelDrag();
|
|
2473
2583
|
document.body.style.cursor = mode === "off" ? "" : "crosshair";
|
|
2474
2584
|
this.toolbar.querySelector('[data-role="inspect"]')?.classList.toggle("on", mode === "inspect");
|
|
2585
|
+
this.toolbar.querySelector('[data-role="free"]')?.classList.toggle("on", mode === "free");
|
|
2475
2586
|
this.toolbar.querySelector('[data-role="region"]')?.classList.toggle("on", mode === "region");
|
|
2476
2587
|
document.removeEventListener("mousemove", this.onMove, true);
|
|
2477
2588
|
document.removeEventListener("click", this.onClick, true);
|
|
2589
|
+
document.removeEventListener("click", this.onFreeClick, true);
|
|
2478
2590
|
document.removeEventListener("mousedown", this.onRegionDown, true);
|
|
2479
2591
|
if (mode === "off") return;
|
|
2480
2592
|
document.addEventListener("keydown", this.onKey, true);
|
|
@@ -2482,6 +2594,8 @@ var Loupe = (() => {
|
|
|
2482
2594
|
if (mode === "inspect") {
|
|
2483
2595
|
document.addEventListener("mousemove", this.onMove, true);
|
|
2484
2596
|
document.addEventListener("click", this.onClick, true);
|
|
2597
|
+
} else if (mode === "free") {
|
|
2598
|
+
document.addEventListener("click", this.onFreeClick, true);
|
|
2485
2599
|
} else if (mode === "region") {
|
|
2486
2600
|
document.addEventListener("mousedown", this.onRegionDown, true);
|
|
2487
2601
|
}
|
|
@@ -2494,16 +2608,22 @@ var Loupe = (() => {
|
|
|
2494
2608
|
const label = el(
|
|
2495
2609
|
"div",
|
|
2496
2610
|
"target",
|
|
2497
|
-
target.kind === "element" ? describe(target.element) : `Region \xB7 ${Math.round(target.region.w)}\xD7${Math.round(target.region.h)} px`
|
|
2611
|
+
target.kind === "element" ? describe(target.element) : target.kind === "region" ? `Region \xB7 ${Math.round(target.region.w)}\xD7${Math.round(target.region.h)} px` : "Free note \xB7 anywhere on the page"
|
|
2498
2612
|
);
|
|
2499
2613
|
const ta = el("textarea");
|
|
2500
|
-
ta.placeholder = target.kind === "region" ? "What's the issue in this area?" : "What should change here?";
|
|
2614
|
+
ta.placeholder = target.kind === "region" ? "What's the issue in this area?" : target.kind === "free" ? "Leave a note about this page\u2026" : "What should change here?";
|
|
2501
2615
|
const row = el("div", "row");
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2616
|
+
let box = null;
|
|
2617
|
+
if (target.kind !== "free") {
|
|
2618
|
+
const chk = el("label", "chk");
|
|
2619
|
+
box = document.createElement("input");
|
|
2620
|
+
box.type = "checkbox";
|
|
2621
|
+
box.checked = true;
|
|
2622
|
+
chk.append(box, document.createTextNode("Attach screenshot"));
|
|
2623
|
+
row.append(chk);
|
|
2624
|
+
} else {
|
|
2625
|
+
row.style.justifyContent = "flex-end";
|
|
2626
|
+
}
|
|
2507
2627
|
const btns = el("div", "btns");
|
|
2508
2628
|
const cancel = el("button", "ghost", "Cancel");
|
|
2509
2629
|
cancel.onclick = () => this.closeComposer();
|
|
@@ -2512,9 +2632,9 @@ var Loupe = (() => {
|
|
|
2512
2632
|
ta.oninput = () => {
|
|
2513
2633
|
save.disabled = !ta.value.trim();
|
|
2514
2634
|
};
|
|
2515
|
-
save.onclick = () => this.submit(target, ta.value.trim(), box.checked);
|
|
2635
|
+
save.onclick = () => this.submit(target, ta.value.trim(), box ? box.checked : false);
|
|
2516
2636
|
btns.append(cancel, save);
|
|
2517
|
-
row.append(
|
|
2637
|
+
row.append(btns);
|
|
2518
2638
|
c.append(label, ta, row);
|
|
2519
2639
|
const w = 300, h = 190;
|
|
2520
2640
|
const left = Math.min(Math.max(8, x + 12), window.innerWidth - w - 8);
|
|
@@ -2545,6 +2665,11 @@ var Loupe = (() => {
|
|
|
2545
2665
|
context = captureElementContext(target.element);
|
|
2546
2666
|
offset = this.targetOffset;
|
|
2547
2667
|
anchoredEl = target.element;
|
|
2668
|
+
} else if (target.kind === "free") {
|
|
2669
|
+
screenshot = void 0;
|
|
2670
|
+
anchor = pageAnchor(target.point);
|
|
2671
|
+
context = { html: "", styles: {} };
|
|
2672
|
+
offset = target.offset;
|
|
2548
2673
|
} else {
|
|
2549
2674
|
screenshot = withShot ? target.screenshot ?? await this.pendingShot : void 0;
|
|
2550
2675
|
region = target.region;
|
|
@@ -2604,6 +2729,7 @@ var Loupe = (() => {
|
|
|
2604
2729
|
}
|
|
2605
2730
|
pin.textContent = String(i + 1);
|
|
2606
2731
|
pin.classList.toggle("done", c.status === "done");
|
|
2732
|
+
pin.classList.toggle("free", c.kind === "free");
|
|
2607
2733
|
});
|
|
2608
2734
|
this.countEl.textContent = String(this.comments.length);
|
|
2609
2735
|
this.position();
|
|
@@ -2631,6 +2757,7 @@ var Loupe = (() => {
|
|
|
2631
2757
|
};
|
|
2632
2758
|
window.addEventListener("scroll", reposition, true);
|
|
2633
2759
|
window.addEventListener("resize", reposition);
|
|
2760
|
+
window.addEventListener("resize", this.onResizeBar);
|
|
2634
2761
|
let debounce = 0;
|
|
2635
2762
|
this.mo = new MutationObserver(() => {
|
|
2636
2763
|
clearTimeout(debounce);
|
|
@@ -2664,6 +2791,86 @@ var Loupe = (() => {
|
|
|
2664
2791
|
this.overlay.style.display = "";
|
|
2665
2792
|
this.renderPins();
|
|
2666
2793
|
}
|
|
2794
|
+
this.layoutBar();
|
|
2795
|
+
}
|
|
2796
|
+
loadBarPos() {
|
|
2797
|
+
try {
|
|
2798
|
+
const s = localStorage.getItem("loupe:bar");
|
|
2799
|
+
if (s) {
|
|
2800
|
+
const p = JSON.parse(s);
|
|
2801
|
+
if (typeof p?.fx === "number" && typeof p?.fy === "number") this.barPos = p;
|
|
2802
|
+
}
|
|
2803
|
+
} catch {
|
|
2804
|
+
}
|
|
2805
|
+
}
|
|
2806
|
+
saveBarPos() {
|
|
2807
|
+
try {
|
|
2808
|
+
if (this.barPos) localStorage.setItem("loupe:bar", JSON.stringify(this.barPos));
|
|
2809
|
+
} catch {
|
|
2810
|
+
}
|
|
2811
|
+
}
|
|
2812
|
+
brandEl() {
|
|
2813
|
+
return this.toolbar.querySelector(".brand");
|
|
2814
|
+
}
|
|
2815
|
+
/**
|
|
2816
|
+
* Position the toolbar for its stored spot and make it expand *inward* so it's
|
|
2817
|
+
* always fully on-screen: docked to a left/right edge → vertical; to a
|
|
2818
|
+
* top/bottom edge or a corner → horizontal. No stored spot → default
|
|
2819
|
+
* (bottom-center, governed by CSS).
|
|
2820
|
+
*/
|
|
2821
|
+
layoutBar() {
|
|
2822
|
+
const bar = this.toolbar;
|
|
2823
|
+
if (!this.barPos) {
|
|
2824
|
+
bar.classList.remove("floating", "orient-h", "orient-v", "flow-rev");
|
|
2825
|
+
for (const p of ["left", "top", "right", "bottom", "transform"]) bar.style[p] = "";
|
|
2826
|
+
return;
|
|
2827
|
+
}
|
|
2828
|
+
const vw = window.innerWidth, vh = window.innerHeight;
|
|
2829
|
+
const brand = this.brandEl();
|
|
2830
|
+
const iw = brand.offsetWidth || 44, ih = brand.offsetHeight || 44;
|
|
2831
|
+
const ix = clampPx(this.barPos.fx * vw, 6, Math.max(6, vw - iw - 6));
|
|
2832
|
+
const iy = clampPx(this.barPos.fy * vh, 6, Math.max(6, vh - ih - 6));
|
|
2833
|
+
const cx = ix + iw / 2, cy = iy + ih / 2;
|
|
2834
|
+
const horizontalSide = cx < vw / 2 ? "left" : "right";
|
|
2835
|
+
const verticalSide = cy < vh / 2 ? "top" : "bottom";
|
|
2836
|
+
const vertical = Math.min(cx, vw - cx) < Math.min(cy, vh - cy);
|
|
2837
|
+
bar.classList.add("floating");
|
|
2838
|
+
bar.classList.remove("dragging");
|
|
2839
|
+
bar.classList.toggle("orient-v", vertical);
|
|
2840
|
+
bar.classList.toggle("orient-h", !vertical);
|
|
2841
|
+
bar.classList.toggle("flow-rev", vertical ? verticalSide === "bottom" : horizontalSide === "right");
|
|
2842
|
+
bar.style.transform = "none";
|
|
2843
|
+
if (horizontalSide === "left") {
|
|
2844
|
+
bar.style.left = ix + "px";
|
|
2845
|
+
bar.style.right = "auto";
|
|
2846
|
+
} else {
|
|
2847
|
+
bar.style.right = vw - (ix + iw) + "px";
|
|
2848
|
+
bar.style.left = "auto";
|
|
2849
|
+
}
|
|
2850
|
+
if (verticalSide === "top") {
|
|
2851
|
+
bar.style.top = iy + "px";
|
|
2852
|
+
bar.style.bottom = "auto";
|
|
2853
|
+
} else {
|
|
2854
|
+
bar.style.bottom = vh - (iy + ih) + "px";
|
|
2855
|
+
bar.style.top = "auto";
|
|
2856
|
+
}
|
|
2857
|
+
const r = bar.getBoundingClientRect();
|
|
2858
|
+
if (r.right > vw - 6 && bar.style.left !== "auto") {
|
|
2859
|
+
bar.style.left = "auto";
|
|
2860
|
+
bar.style.right = "6px";
|
|
2861
|
+
}
|
|
2862
|
+
if (r.left < 6 && bar.style.right !== "auto") {
|
|
2863
|
+
bar.style.right = "auto";
|
|
2864
|
+
bar.style.left = "6px";
|
|
2865
|
+
}
|
|
2866
|
+
if (r.bottom > vh - 6 && bar.style.top !== "auto") {
|
|
2867
|
+
bar.style.top = "auto";
|
|
2868
|
+
bar.style.bottom = "6px";
|
|
2869
|
+
}
|
|
2870
|
+
if (r.top < 6 && bar.style.bottom !== "auto") {
|
|
2871
|
+
bar.style.bottom = "auto";
|
|
2872
|
+
bar.style.top = "6px";
|
|
2873
|
+
}
|
|
2667
2874
|
}
|
|
2668
2875
|
renderPanel() {
|
|
2669
2876
|
const p = this.panel;
|
|
@@ -2676,7 +2883,7 @@ var Loupe = (() => {
|
|
|
2676
2883
|
p.appendChild(head);
|
|
2677
2884
|
const list = el("div", "list");
|
|
2678
2885
|
if (!this.comments.length) {
|
|
2679
|
-
list.appendChild(el("div", "empty", "No comments yet. Click \u201CInspect & comment\u201D,
|
|
2886
|
+
list.appendChild(el("div", "empty", "No comments yet. Click \u201CInspect & comment\u201D and pick an element, or \u201CNote\u201D to drop a comment anywhere."));
|
|
2680
2887
|
}
|
|
2681
2888
|
this.comments.forEach((c, i) => list.appendChild(this.itemView(c, i)));
|
|
2682
2889
|
p.appendChild(list);
|
|
@@ -2735,7 +2942,13 @@ var Loupe = (() => {
|
|
|
2735
2942
|
return item;
|
|
2736
2943
|
}
|
|
2737
2944
|
async copyForClaude(c) {
|
|
2738
|
-
const
|
|
2945
|
+
const lines = c.kind === "free" ? [
|
|
2946
|
+
`# Product feedback from ${c.author.name}`,
|
|
2947
|
+
``,
|
|
2948
|
+
`**Note:** ${c.body}`,
|
|
2949
|
+
`**Page:** ${c.url}`,
|
|
2950
|
+
`**Type:** Free note \u2014 a page-level comment not tied to a specific element.`
|
|
2951
|
+
] : [
|
|
2739
2952
|
`# Product feedback from ${c.author.name}`,
|
|
2740
2953
|
``,
|
|
2741
2954
|
`**Comment:** ${c.body}`,
|
|
@@ -2752,7 +2965,8 @@ var Loupe = (() => {
|
|
|
2752
2965
|
"```json",
|
|
2753
2966
|
JSON.stringify(c.context.styles, null, 2),
|
|
2754
2967
|
"```"
|
|
2755
|
-
]
|
|
2968
|
+
];
|
|
2969
|
+
const prompt = lines.filter(Boolean).join("\n");
|
|
2756
2970
|
try {
|
|
2757
2971
|
await navigator.clipboard.writeText(prompt);
|
|
2758
2972
|
} catch {
|
|
@@ -2762,6 +2976,19 @@ var Loupe = (() => {
|
|
|
2762
2976
|
flash(id) {
|
|
2763
2977
|
const c = this.comments.find((x) => x.id === id);
|
|
2764
2978
|
const pin = this.pins.get(id);
|
|
2979
|
+
if (c?.kind === "free") {
|
|
2980
|
+
for (const p of this.pins.values()) p.classList.remove("active");
|
|
2981
|
+
pin?.classList.add("active");
|
|
2982
|
+
const docW = Math.max(1, document.documentElement.scrollWidth);
|
|
2983
|
+
const docH = Math.max(1, document.documentElement.scrollHeight);
|
|
2984
|
+
window.scrollTo({
|
|
2985
|
+
top: Math.max(0, c.offset.y * docH - window.innerHeight / 2),
|
|
2986
|
+
left: Math.max(0, c.offset.x * docW - window.innerWidth / 2),
|
|
2987
|
+
behavior: "smooth"
|
|
2988
|
+
});
|
|
2989
|
+
this.position();
|
|
2990
|
+
return;
|
|
2991
|
+
}
|
|
2765
2992
|
if (c?.kind === "region" && c.region) {
|
|
2766
2993
|
for (const p of this.pins.values()) p.classList.remove("active");
|
|
2767
2994
|
pin?.classList.add("active");
|
|
@@ -2788,6 +3015,7 @@ var Loupe = (() => {
|
|
|
2788
3015
|
this.mo?.disconnect();
|
|
2789
3016
|
if (this.tick) clearInterval(this.tick);
|
|
2790
3017
|
window.clearTimeout(this.regionTimer);
|
|
3018
|
+
window.removeEventListener("resize", this.onResizeBar);
|
|
2791
3019
|
document.removeEventListener("keydown", this.onKey, true);
|
|
2792
3020
|
this.root?.remove();
|
|
2793
3021
|
}
|
|
@@ -2804,7 +3032,24 @@ var Loupe = (() => {
|
|
|
2804
3032
|
function clamp(n) {
|
|
2805
3033
|
return Math.max(0, Math.min(1, n));
|
|
2806
3034
|
}
|
|
3035
|
+
function clampPx(n, min, max) {
|
|
3036
|
+
return Math.max(min, Math.min(max, n));
|
|
3037
|
+
}
|
|
2807
3038
|
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>`;
|
|
3039
|
+
var NOTE_ICON = `<svg class="ico" width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true"><path d="M2 2.5h11v7.5H6l-3 2.5v-2.5H2z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/></svg>`;
|
|
3040
|
+
function pageAnchor(point) {
|
|
3041
|
+
return {
|
|
3042
|
+
tag: "page",
|
|
3043
|
+
cssPath: "page",
|
|
3044
|
+
xpath: "",
|
|
3045
|
+
testid: null,
|
|
3046
|
+
text: "",
|
|
3047
|
+
attrs: {},
|
|
3048
|
+
nthOfType: 1,
|
|
3049
|
+
rect: { x: Math.round(point.x), y: Math.round(point.y), w: 0, h: 0 },
|
|
3050
|
+
viewport: { w: window.innerWidth, h: window.innerHeight }
|
|
3051
|
+
};
|
|
3052
|
+
}
|
|
2808
3053
|
function regionAnchor(region) {
|
|
2809
3054
|
return {
|
|
2810
3055
|
tag: "region",
|
|
@@ -2830,6 +3075,7 @@ var Loupe = (() => {
|
|
|
2830
3075
|
return txt ? `${tag} \xB7 \u201C${txt}\u201D` : tag;
|
|
2831
3076
|
}
|
|
2832
3077
|
function describeAnchor(c) {
|
|
3078
|
+
if (c.kind === "free") return "Free note \xB7 page-level";
|
|
2833
3079
|
return c.anchor.testid ? `[data-testid="${c.anchor.testid}"]` : c.anchor.cssPath;
|
|
2834
3080
|
}
|
|
2835
3081
|
|