@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/README.md +130 -0
- package/dist/index.global.js +301 -47
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +301 -47
- package/dist/index.js.map +1 -1
- package/package.json +21 -6
package/dist/index.global.js
CHANGED
|
@@ -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
|
-
:
|
|
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.
|
|
2070
|
-
this.target = null;
|
|
2134
|
+
this.mode = "off";
|
|
2071
2135
|
this.targetOffset = { x: 0.5, y: 0.5 };
|
|
2072
|
-
this.
|
|
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 (
|
|
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 (
|
|
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.
|
|
2104
|
-
this.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
2196
|
-
this.
|
|
2364
|
+
setMode(mode) {
|
|
2365
|
+
this.mode = mode;
|
|
2197
2366
|
this.hl.style.display = "none";
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
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
|
-
|
|
2205
|
-
this.
|
|
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(
|
|
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.
|
|
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
|
-
|
|
2254
|
-
|
|
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
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
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,
|
|
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.
|
|
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();
|