@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.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
|
-
:
|
|
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.
|
|
2043
|
-
this.target = null;
|
|
2107
|
+
this.mode = "off";
|
|
2044
2108
|
this.targetOffset = { x: 0.5, y: 0.5 };
|
|
2045
|
-
this.
|
|
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 (
|
|
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 (
|
|
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.
|
|
2077
|
-
this.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
2169
|
-
this.
|
|
2337
|
+
setMode(mode) {
|
|
2338
|
+
this.mode = mode;
|
|
2170
2339
|
this.hl.style.display = "none";
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
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
|
-
|
|
2178
|
-
this.
|
|
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(
|
|
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.
|
|
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
|
-
|
|
2227
|
-
|
|
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
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
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,
|
|
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.
|
|
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();
|