@hansenexus/hud 0.2.0 → 0.4.0
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 +253 -9
- package/dist/access-CNvJOwKM.d.ts +54 -0
- package/dist/capabilities-CjulXFQX.js +189 -0
- package/dist/define-hud-BBzsSl8b.d.ts +36 -0
- package/dist/env-DfxpPfs9.js +28 -0
- package/dist/env-vEjyJfPW.d.ts +17 -0
- package/dist/force-state-BaqWsmrf.js +153 -0
- package/dist/force-state-CCqoTfVB.d.ts +23 -0
- package/dist/handler-WmE4Lu7l.d.ts +39 -0
- package/dist/index-BYsdS-7d.d.ts +55 -0
- package/dist/index.d.ts +6 -39
- package/dist/index.js +980 -264
- package/dist/marker-C66LxOhJ.d.ts +9 -0
- package/dist/marker-CMrvxPmS.d.ts +12 -0
- package/dist/marker-DjeGvL-F.js +9 -0
- package/dist/marker-uZYPOJ4M.js +12 -0
- package/dist/markers.d.ts +3 -0
- package/dist/markers.js +3 -0
- package/dist/next.d.ts +33 -0
- package/dist/next.js +52 -0
- package/dist/payload-CLKDVZWl.d.ts +68 -0
- package/dist/plugins/access/server.d.ts +122 -0
- package/dist/plugins/access/server.js +339 -0
- package/dist/plugins/access.d.ts +41 -0
- package/dist/plugins/access.js +250 -0
- package/dist/plugins/agent/server.d.ts +129 -0
- package/dist/plugins/agent/server.js +429 -0
- package/dist/plugins/agent.d.ts +37 -0
- package/dist/plugins/agent.js +411 -0
- package/dist/plugins/design.d.ts +125 -0
- package/dist/plugins/design.js +619 -0
- package/dist/plugins/observe.d.ts +64 -0
- package/dist/plugins/observe.js +617 -0
- package/dist/plugins/ops/server.d.ts +82 -0
- package/dist/plugins/ops/server.js +380 -0
- package/dist/plugins/ops.d.ts +26 -0
- package/dist/plugins/ops.js +362 -0
- package/dist/plugins/vitals.d.ts +25 -0
- package/dist/plugins/vitals.js +26 -0
- package/dist/plugins-CQ_0w1ot.d.ts +114 -0
- package/dist/plugins-DVy_vaYr.js +70 -0
- package/dist/review.d.ts +181 -0
- package/dist/review.js +1017 -0
- package/dist/selector-NSYSe7AZ.js +28 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +106 -0
- package/dist/shortcut-U6ftVY06.js +222 -0
- package/dist/state-boundary.d.ts +26 -0
- package/dist/state-boundary.js +61 -0
- package/dist/styles--slk86ZQ.js +332 -0
- package/dist/types-DADBRiGk.d.ts +50 -0
- package/dist/types-QJUajwWq.js +35 -0
- package/dist/types-f3jlNxzw.d.ts +92 -0
- package/dist/vitals-panel-Dvc5dnR-.js +87 -0
- package/package.json +36 -3
package/dist/review.js
ADDED
|
@@ -0,0 +1,1017 @@
|
|
|
1
|
+
import { n as buildAnchorSelector, t as anchorLabelFor } from "./selector-NSYSe7AZ.js";
|
|
2
|
+
import { i as PICKER_CSS, n as ShadowHost, r as ElementPicker, t as HUD_CSS } from "./styles--slk86ZQ.js";
|
|
3
|
+
import { t as HUD_REVIEW_MARKER } from "./marker-DjeGvL-F.js";
|
|
4
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
5
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
+
import { ConvexClient } from "convex/browser";
|
|
7
|
+
import { makeFunctionReference } from "convex/server";
|
|
8
|
+
//#region src/review/anchor/viewport.ts
|
|
9
|
+
/** Tailwind's `md` (768) and `xl` (1280) — the breakpoints the apps already lay out against. */
|
|
10
|
+
const BUCKET_BREAKPOINTS = {
|
|
11
|
+
tablet: 768,
|
|
12
|
+
desktop: 1280
|
|
13
|
+
};
|
|
14
|
+
function viewportBucket(width) {
|
|
15
|
+
if (width < BUCKET_BREAKPOINTS.tablet) return "mobile";
|
|
16
|
+
if (width < BUCKET_BREAKPOINTS.desktop) return "tablet";
|
|
17
|
+
return "desktop";
|
|
18
|
+
}
|
|
19
|
+
const BUCKET_LABEL = {
|
|
20
|
+
mobile: "Mobil",
|
|
21
|
+
tablet: "Tablet",
|
|
22
|
+
desktop: "Desktop"
|
|
23
|
+
};
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/review/anchor/capture.ts
|
|
26
|
+
/**
|
|
27
|
+
* Ids that are regenerated on every mount. Radix hands its triggers ids like
|
|
28
|
+
* `radix-:r1h:`, so without this denylist the "most stable" strategy would be
|
|
29
|
+
* the least stable while claiming otherwise.
|
|
30
|
+
*/
|
|
31
|
+
const GENERATED_ID = /^(radix-|headlessui-|react-aria|:r[0-9a-z]+:|\d)/i;
|
|
32
|
+
/** Attributes worth trying as a stable handle, most intentional first. */
|
|
33
|
+
const STABLE_ATTRS = [
|
|
34
|
+
"data-testid",
|
|
35
|
+
"data-review-id",
|
|
36
|
+
"data-slot"
|
|
37
|
+
];
|
|
38
|
+
/** How far up to look for a stable ancestor before giving up. */
|
|
39
|
+
const STABLE_ANCESTOR_DEPTH = 3;
|
|
40
|
+
/** Longer than this and the element's text identifies a whole section, not an element. */
|
|
41
|
+
const MAX_TEXT_SOURCE = 400;
|
|
42
|
+
const MAX_TEXT_PREFIX = 120;
|
|
43
|
+
function escapeIdent(value) {
|
|
44
|
+
const css = globalThis.CSS;
|
|
45
|
+
return css?.escape ? css.escape(value) : value.replace(/["\\]/g, "\\$&");
|
|
46
|
+
}
|
|
47
|
+
function escapeAttrValue(value) {
|
|
48
|
+
return value.replace(/["\\]/g, "\\$&");
|
|
49
|
+
}
|
|
50
|
+
function isGeneratedId(value) {
|
|
51
|
+
return value.length === 0 || GENERATED_ID.test(value);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* A stable selector for `el` itself, or null.
|
|
55
|
+
*
|
|
56
|
+
* Uniqueness is checked, not assumed: shadcn stamps `data-slot="button"` on
|
|
57
|
+
* every button in the tree, so an unchecked attribute selector would be a
|
|
58
|
+
* stable-looking handle onto forty different nodes.
|
|
59
|
+
*/
|
|
60
|
+
function stableSelectorFor(el, doc) {
|
|
61
|
+
const candidates = [];
|
|
62
|
+
const id = el.getAttribute("id");
|
|
63
|
+
if (id && !isGeneratedId(id)) candidates.push(`#${escapeIdent(id)}`);
|
|
64
|
+
for (const attr of STABLE_ATTRS) {
|
|
65
|
+
const value = el.getAttribute(attr);
|
|
66
|
+
if (value && !isGeneratedId(value)) candidates.push(`[${attr}="${escapeAttrValue(value)}"]`);
|
|
67
|
+
}
|
|
68
|
+
for (const candidate of candidates) try {
|
|
69
|
+
if (doc.querySelectorAll(candidate).length === 1) return candidate;
|
|
70
|
+
} catch {}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
function findStableAncestor(el, doc) {
|
|
74
|
+
let node = el;
|
|
75
|
+
for (let depth = 0; node && depth <= STABLE_ANCESTOR_DEPTH; depth++) {
|
|
76
|
+
const selector = stableSelectorFor(node, doc);
|
|
77
|
+
if (selector) return {
|
|
78
|
+
selector,
|
|
79
|
+
node
|
|
80
|
+
};
|
|
81
|
+
node = node.parentElement;
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
function normaliseText(value) {
|
|
86
|
+
return value.trim().replace(/\s+/g, " ");
|
|
87
|
+
}
|
|
88
|
+
function textPrefixFor(el) {
|
|
89
|
+
const raw = el.textContent ?? "";
|
|
90
|
+
if (raw.length === 0 || raw.length > MAX_TEXT_SOURCE) return void 0;
|
|
91
|
+
const text = normaliseText(raw);
|
|
92
|
+
return text.length > 0 ? text.slice(0, MAX_TEXT_PREFIX) : void 0;
|
|
93
|
+
}
|
|
94
|
+
/** Attributes that identify an element which carries no text of its own. */
|
|
95
|
+
const SIGNATURE_ATTRS = [
|
|
96
|
+
"aria-label",
|
|
97
|
+
"alt",
|
|
98
|
+
"title",
|
|
99
|
+
"name",
|
|
100
|
+
"src",
|
|
101
|
+
"href"
|
|
102
|
+
];
|
|
103
|
+
/**
|
|
104
|
+
* A cheap identity for text-less elements. `src`/`href` are reduced to their
|
|
105
|
+
* last path segment so a CDN hash or a locale prefix does not invalidate it.
|
|
106
|
+
*/
|
|
107
|
+
function signatureFor(el) {
|
|
108
|
+
for (const attr of SIGNATURE_ATTRS) {
|
|
109
|
+
const raw = el.getAttribute(attr);
|
|
110
|
+
if (!raw) continue;
|
|
111
|
+
const value = attr === "src" || attr === "href" ? (raw.split("?")[0] ?? "").split("/").filter(Boolean).pop() : normaliseText(raw);
|
|
112
|
+
if (value) return `${attr}=${value.slice(0, 80)}`;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function clamp01(value) {
|
|
116
|
+
if (!Number.isFinite(value)) return .5;
|
|
117
|
+
return Math.min(1, Math.max(0, value));
|
|
118
|
+
}
|
|
119
|
+
function round(value, places = 4) {
|
|
120
|
+
return Number(value.toFixed(places));
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Snapshot everything needed to find this element again on a later render, at a
|
|
124
|
+
* possibly different scroll offset, viewport and content revision.
|
|
125
|
+
*/
|
|
126
|
+
function capturePinAnchor(el, point) {
|
|
127
|
+
const doc = el.ownerDocument;
|
|
128
|
+
const win = doc.defaultView;
|
|
129
|
+
const root = doc.documentElement;
|
|
130
|
+
const rect = el.getBoundingClientRect();
|
|
131
|
+
const scrollX = win?.scrollX ?? 0;
|
|
132
|
+
const scrollY = win?.scrollY ?? 0;
|
|
133
|
+
const viewportWidth = win?.innerWidth ?? root.clientWidth ?? 0;
|
|
134
|
+
const viewportHeight = win?.innerHeight ?? root.clientHeight ?? 0;
|
|
135
|
+
const docWidth = Math.max(root.scrollWidth, viewportWidth, 1);
|
|
136
|
+
const docHeight = Math.max(root.scrollHeight, viewportHeight, 1);
|
|
137
|
+
const pct = (part, whole) => whole > 0 ? round(part / whole * 100, 1) : 0;
|
|
138
|
+
const anchor = {
|
|
139
|
+
tag: el.tagName.toLowerCase(),
|
|
140
|
+
selector: buildAnchorSelector(el, doc.body),
|
|
141
|
+
label: anchorLabelFor(el),
|
|
142
|
+
offsetX: point && rect.width > 0 ? clamp01((point.clientX - rect.left) / rect.width) : .5,
|
|
143
|
+
offsetY: point && rect.height > 0 ? clamp01((point.clientY - rect.top) / rect.height) : .5,
|
|
144
|
+
docX: round(clamp01((rect.left + scrollX) / docWidth)),
|
|
145
|
+
docY: round(clamp01((rect.top + scrollY) / docHeight)),
|
|
146
|
+
rect: {
|
|
147
|
+
x: pct(rect.left, viewportWidth),
|
|
148
|
+
y: pct(rect.top, viewportHeight),
|
|
149
|
+
width: pct(rect.width, viewportWidth),
|
|
150
|
+
height: pct(rect.height, viewportHeight)
|
|
151
|
+
},
|
|
152
|
+
viewportWidth,
|
|
153
|
+
viewportHeight,
|
|
154
|
+
scrollY,
|
|
155
|
+
viewportBucket: viewportBucket(viewportWidth)
|
|
156
|
+
};
|
|
157
|
+
const stable = findStableAncestor(el, doc);
|
|
158
|
+
if (stable) {
|
|
159
|
+
anchor.stableId = stable.selector;
|
|
160
|
+
anchor.selectorFromStable = stable.node === el ? "" : buildAnchorSelector(el, stable.node);
|
|
161
|
+
}
|
|
162
|
+
const textPrefix = textPrefixFor(el);
|
|
163
|
+
if (textPrefix) anchor.textPrefix = textPrefix;
|
|
164
|
+
const signature = signatureFor(el);
|
|
165
|
+
if (signature) anchor.signature = signature;
|
|
166
|
+
return anchor;
|
|
167
|
+
}
|
|
168
|
+
//#endregion
|
|
169
|
+
//#region src/review/anchor/resolve.ts
|
|
170
|
+
/**
|
|
171
|
+
* Is this element something a pin can actually point at?
|
|
172
|
+
*
|
|
173
|
+
* Deliberately not `offsetParent !== null`: that is null for every
|
|
174
|
+
* `position: fixed` element, which would exile pins on sticky headers and
|
|
175
|
+
* cookie banners — exactly the chrome clients comment on most.
|
|
176
|
+
*/
|
|
177
|
+
function isUsable(el) {
|
|
178
|
+
if (!el.isConnected) return false;
|
|
179
|
+
const rect = el.getBoundingClientRect();
|
|
180
|
+
return rect.width > 0 || rect.height > 0;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Does this candidate look like the element the pin was planted on?
|
|
184
|
+
*
|
|
185
|
+
* A tag check alone is not enough, and assuming otherwise is the single
|
|
186
|
+
* sharpest edge here: these pages render Convex-backed lists, so inserting one
|
|
187
|
+
* row shifts every `nth-of-type` below it and the stored path then resolves to
|
|
188
|
+
* a *different node of the same tag*. The resolver would return it with an
|
|
189
|
+
* "exact" badge and be confidently wrong — worse than returning nothing,
|
|
190
|
+
* because nothing is visibly a miss and a wrong element is not.
|
|
191
|
+
*
|
|
192
|
+
* So a path hit must also match whatever identity we stored: the leading text,
|
|
193
|
+
* or for text-less elements (icon buttons, images, inputs) an attribute
|
|
194
|
+
* signature. With neither available the path stands alone, which is the
|
|
195
|
+
* weakest case and unavoidable.
|
|
196
|
+
*/
|
|
197
|
+
function corroborates(el, anchor) {
|
|
198
|
+
if (el.tagName.toLowerCase() !== anchor.tag) return false;
|
|
199
|
+
if (anchor.textPrefix) return normaliseText(el.textContent ?? "").startsWith(anchor.textPrefix);
|
|
200
|
+
if (anchor.signature) return signatureFor(el) === anchor.signature;
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
function query(root, selector) {
|
|
204
|
+
if (!selector) return null;
|
|
205
|
+
try {
|
|
206
|
+
return root.querySelector(selector);
|
|
207
|
+
} catch {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
function queryAll(root, selector) {
|
|
212
|
+
if (!selector) return [];
|
|
213
|
+
try {
|
|
214
|
+
return [...root.querySelectorAll(selector)];
|
|
215
|
+
} catch {
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/** Strategy 1 — the stored path, corroborated. */
|
|
220
|
+
function byExactSelector(anchor, doc) {
|
|
221
|
+
const el = query(doc, anchor.selector);
|
|
222
|
+
if (!el || !isUsable(el) || !corroborates(el, anchor)) return null;
|
|
223
|
+
return el;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Strategy 2 — a unique, non-generated handle, then the path *below* it.
|
|
227
|
+
*
|
|
228
|
+
* Storing only the handle would make this a coarse fallback; storing the tail
|
|
229
|
+
* as well makes it independent of everything above the handle, which is
|
|
230
|
+
* precisely what content-driven reshuffles change. The tail is corroborated
|
|
231
|
+
* too — it carries the same `nth-of-type` fragility as the full path, just
|
|
232
|
+
* over a shorter distance.
|
|
233
|
+
*/
|
|
234
|
+
function byStableId(anchor, doc) {
|
|
235
|
+
if (!anchor.stableId) return null;
|
|
236
|
+
const host = query(doc, anchor.stableId);
|
|
237
|
+
if (!host) return null;
|
|
238
|
+
const candidate = anchor.selectorFromStable ? query(host, anchor.selectorFromStable) : host;
|
|
239
|
+
if (!candidate || !isUsable(candidate) || !corroborates(candidate, anchor)) return null;
|
|
240
|
+
return candidate;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Strategy 3 — scan every element of the same tag for the one whose identity
|
|
244
|
+
* matches, ignoring structure entirely.
|
|
245
|
+
*
|
|
246
|
+
* This is the strategy that survives a reshuffle, because it does not look at
|
|
247
|
+
* the path at all. It matches on whichever identity was stored — leading text,
|
|
248
|
+
* or an attribute signature for text-less elements like icon buttons — so both
|
|
249
|
+
* kinds of element are recoverable and not just the ones with words in them.
|
|
250
|
+
*
|
|
251
|
+
* It only runs when there IS an identity: with neither stored, `corroborates`
|
|
252
|
+
* accepts every element of that tag, and picking the nearest one would be a
|
|
253
|
+
* guess dressed as a result.
|
|
254
|
+
*
|
|
255
|
+
* Text matching is locale-dependent by nature — a pin planted on the German
|
|
256
|
+
* site will not match by text on `/en` — which is why a pin also stores its
|
|
257
|
+
* locale.
|
|
258
|
+
*/
|
|
259
|
+
function byIdentity(anchor, doc) {
|
|
260
|
+
if (!anchor.textPrefix && !anchor.signature) return null;
|
|
261
|
+
const matches = queryAll(doc, anchor.tag).filter((el) => isUsable(el) && corroborates(el, anchor));
|
|
262
|
+
if (matches.length === 0) return null;
|
|
263
|
+
if (matches.length === 1) return matches[0] ?? null;
|
|
264
|
+
const root = doc.documentElement;
|
|
265
|
+
const win = doc.defaultView;
|
|
266
|
+
const docHeight = Math.max(root.scrollHeight, win?.innerHeight ?? 0, 1);
|
|
267
|
+
const scrollY = win?.scrollY ?? 0;
|
|
268
|
+
let best = null;
|
|
269
|
+
let bestDelta = Number.POSITIVE_INFINITY;
|
|
270
|
+
for (const el of matches) {
|
|
271
|
+
const docY = (el.getBoundingClientRect().top + scrollY) / docHeight;
|
|
272
|
+
const delta = Math.abs(docY - anchor.docY);
|
|
273
|
+
if (delta < bestDelta) {
|
|
274
|
+
bestDelta = delta;
|
|
275
|
+
best = el;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return best;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Locate a stored anchor on the current document.
|
|
282
|
+
*
|
|
283
|
+
* Returns null rather than guessing. A null resolution is a first-class
|
|
284
|
+
* outcome, not an error: the pin is shown in the "Ohne Position" list with its
|
|
285
|
+
* screenshot, where it stays actionable — and a pin that stops resolving is
|
|
286
|
+
* itself a signal that the thing it complained about is gone.
|
|
287
|
+
*/
|
|
288
|
+
function resolveAnchor(anchor, doc = document) {
|
|
289
|
+
const exact = byExactSelector(anchor, doc);
|
|
290
|
+
if (exact) return {
|
|
291
|
+
element: exact,
|
|
292
|
+
strategy: "exact"
|
|
293
|
+
};
|
|
294
|
+
const stable = byStableId(anchor, doc);
|
|
295
|
+
if (stable) return {
|
|
296
|
+
element: stable,
|
|
297
|
+
strategy: "stable"
|
|
298
|
+
};
|
|
299
|
+
const identity = byIdentity(anchor, doc);
|
|
300
|
+
if (identity) return {
|
|
301
|
+
element: identity,
|
|
302
|
+
strategy: "identity"
|
|
303
|
+
};
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
//#endregion
|
|
307
|
+
//#region src/review/backend.ts
|
|
308
|
+
/**
|
|
309
|
+
* Function references by name rather than a generated `api` object: the
|
|
310
|
+
* widget is consumed by apps that cannot import the review app's
|
|
311
|
+
* `convex/_generated`. The names are a contract with that deployment.
|
|
312
|
+
*/
|
|
313
|
+
const fn = {
|
|
314
|
+
redeemGrant: makeFunctionReference("access:redeemGrant"),
|
|
315
|
+
whoami: makeFunctionReference("access:whoami"),
|
|
316
|
+
listForRoute: makeFunctionReference("pins:listForRoute"),
|
|
317
|
+
plant: makeFunctionReference("pins:plant")
|
|
318
|
+
};
|
|
319
|
+
/** The review app's Convex deployment at `url`. */
|
|
320
|
+
function convexReview(url) {
|
|
321
|
+
const client = new ConvexClient(url);
|
|
322
|
+
return {
|
|
323
|
+
redeemGrant: (grant) => client.mutation(fn.redeemGrant, { grant }),
|
|
324
|
+
watchReviewer: (sessionToken, onChange) => client.onUpdate(fn.whoami, { sessionToken }, (value) => onChange(value), () => onChange(null)),
|
|
325
|
+
watchPins: (sessionToken, route, onChange) => client.onUpdate(fn.listForRoute, {
|
|
326
|
+
sessionToken,
|
|
327
|
+
route
|
|
328
|
+
}, (value) => onChange(value ?? []), () => onChange([])),
|
|
329
|
+
plant: (pin) => client.mutation(fn.plant, { ...pin }),
|
|
330
|
+
close: () => {
|
|
331
|
+
client.close();
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
//#endregion
|
|
336
|
+
//#region src/review/page-inset.ts
|
|
337
|
+
const STYLE_ID = "hn-review-inset";
|
|
338
|
+
/**
|
|
339
|
+
* Narrow the page so a docked panel covers nothing. OFF by default — see below.
|
|
340
|
+
*
|
|
341
|
+
* `margin-right` on the root element does narrow the layout correctly: the
|
|
342
|
+
* document box and every in-flow element follow it. What does NOT follow is
|
|
343
|
+
* the thing that matters most.
|
|
344
|
+
*
|
|
345
|
+
* **Media queries evaluate against the viewport, not the narrowed box.** Inset
|
|
346
|
+
* elbe-akustik by 360px at a 1183px viewport and the page still believes it is
|
|
347
|
+
* a 1183px desktop: it renders the full desktop nav and then squeezes it into
|
|
348
|
+
* 808px, so the booking CTA overflows its own header. The reviewer is then
|
|
349
|
+
* looking at a layout the client will never see, and can file a bug this tool
|
|
350
|
+
* caused.
|
|
351
|
+
*
|
|
352
|
+
* It also quietly corrupts the pins: `viewportBucket` reads
|
|
353
|
+
* `window.innerWidth`, which the inset does not change, so a pin planted while
|
|
354
|
+
* inset is tagged with a bucket that does not match the layout it was planted
|
|
355
|
+
* against.
|
|
356
|
+
*
|
|
357
|
+
* So the default is an overlay and the page under review is left exactly as the
|
|
358
|
+
* client sees it. The inset stays available as a deliberate per-viewer toggle,
|
|
359
|
+
* for reading long pin threads against a page that does reflow cleanly.
|
|
360
|
+
*
|
|
361
|
+
* Second caveat if you do turn it on: `position: fixed` site chrome does not
|
|
362
|
+
* move with it either — it is positioned against the viewport — so the chat
|
|
363
|
+
* launcher and ProvenExpert badge stay put behind the panel.
|
|
364
|
+
*/
|
|
365
|
+
function applyPageInset(px) {
|
|
366
|
+
if (typeof document === "undefined") return;
|
|
367
|
+
const root = document.documentElement;
|
|
368
|
+
if (px <= 0) {
|
|
369
|
+
root.style.removeProperty("margin-right");
|
|
370
|
+
root.style.removeProperty("transition");
|
|
371
|
+
document.getElementById(STYLE_ID)?.remove();
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (!document.getElementById(STYLE_ID)) {
|
|
375
|
+
const style = document.createElement("style");
|
|
376
|
+
style.id = STYLE_ID;
|
|
377
|
+
style.textContent = ":root{scrollbar-gutter:stable}";
|
|
378
|
+
document.head.append(style);
|
|
379
|
+
}
|
|
380
|
+
root.style.transition = "margin-right 180ms ease";
|
|
381
|
+
root.style.marginRight = `${px}px`;
|
|
382
|
+
}
|
|
383
|
+
/** Docked width, or 0 when the viewport is too narrow to give any away. */
|
|
384
|
+
function dockWidthFor(viewportWidth) {
|
|
385
|
+
return viewportWidth < 768 ? 0 : Math.min(360, Math.round(viewportWidth * .32));
|
|
386
|
+
}
|
|
387
|
+
const PREF_KEY = "hn-review:inset";
|
|
388
|
+
/** Whether this viewer asked for the page to be narrowed. Default off. */
|
|
389
|
+
function readInsetPref() {
|
|
390
|
+
try {
|
|
391
|
+
return localStorage.getItem(PREF_KEY) === "1";
|
|
392
|
+
} catch {
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
function writeInsetPref(on) {
|
|
397
|
+
try {
|
|
398
|
+
localStorage.setItem(PREF_KEY, on ? "1" : "0");
|
|
399
|
+
} catch {}
|
|
400
|
+
}
|
|
401
|
+
//#endregion
|
|
402
|
+
//#region src/review/pin-layer.tsx
|
|
403
|
+
/**
|
|
404
|
+
* Renders the pins for this page inside the review's shadow root.
|
|
405
|
+
*
|
|
406
|
+
* Three outcomes per pin and no fourth: placed, placed with a badge saying the
|
|
407
|
+
* anchor moved, or handed to `onUnplaced` for the "Ohne Position" list. A pin
|
|
408
|
+
* is never silently dropped and never silently moved onto a neighbour.
|
|
409
|
+
*
|
|
410
|
+
* The shadow host is `position: fixed`, so markers sit at viewport
|
|
411
|
+
* coordinates and are re-measured on scroll, at most once per frame.
|
|
412
|
+
*/
|
|
413
|
+
function PinLayer({ pins, activeId, onSelect, onUnplaced }) {
|
|
414
|
+
const [placed, setPlaced] = useState([]);
|
|
415
|
+
const frame = useRef(null);
|
|
416
|
+
const reposition = useCallback(() => {
|
|
417
|
+
const bucket = viewportBucket(window.innerWidth);
|
|
418
|
+
const next = [];
|
|
419
|
+
const missing = [];
|
|
420
|
+
for (const pin of pins) {
|
|
421
|
+
if (pin.viewportBucket !== bucket) {
|
|
422
|
+
missing.push(pin);
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
const hit = resolveAnchor(pin.anchor, document);
|
|
426
|
+
if (!hit) {
|
|
427
|
+
missing.push(pin);
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
const rect = hit.element.getBoundingClientRect();
|
|
431
|
+
next.push({
|
|
432
|
+
pin,
|
|
433
|
+
x: rect.left + rect.width * pin.anchor.offsetX,
|
|
434
|
+
y: rect.top + rect.height * pin.anchor.offsetY,
|
|
435
|
+
strategy: hit.strategy
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
setPlaced(next);
|
|
439
|
+
onUnplaced(missing);
|
|
440
|
+
}, [pins, onUnplaced]);
|
|
441
|
+
const schedule = useCallback(() => {
|
|
442
|
+
if (frame.current !== null) return;
|
|
443
|
+
frame.current = requestAnimationFrame(() => {
|
|
444
|
+
frame.current = null;
|
|
445
|
+
reposition();
|
|
446
|
+
});
|
|
447
|
+
}, [reposition]);
|
|
448
|
+
useEffect(() => {
|
|
449
|
+
reposition();
|
|
450
|
+
window.addEventListener("scroll", schedule, {
|
|
451
|
+
capture: true,
|
|
452
|
+
passive: true
|
|
453
|
+
});
|
|
454
|
+
window.addEventListener("resize", schedule);
|
|
455
|
+
const resizeObserver = new ResizeObserver(schedule);
|
|
456
|
+
resizeObserver.observe(document.documentElement);
|
|
457
|
+
let mutationTimer = null;
|
|
458
|
+
const mutationObserver = new MutationObserver(() => {
|
|
459
|
+
if (mutationTimer) clearTimeout(mutationTimer);
|
|
460
|
+
mutationTimer = setTimeout(schedule, 200);
|
|
461
|
+
});
|
|
462
|
+
mutationObserver.observe(document.body, {
|
|
463
|
+
childList: true,
|
|
464
|
+
subtree: true
|
|
465
|
+
});
|
|
466
|
+
return () => {
|
|
467
|
+
window.removeEventListener("scroll", schedule, { capture: true });
|
|
468
|
+
window.removeEventListener("resize", schedule);
|
|
469
|
+
resizeObserver.disconnect();
|
|
470
|
+
mutationObserver.disconnect();
|
|
471
|
+
if (mutationTimer) clearTimeout(mutationTimer);
|
|
472
|
+
if (frame.current !== null) cancelAnimationFrame(frame.current);
|
|
473
|
+
frame.current = null;
|
|
474
|
+
};
|
|
475
|
+
}, [reposition, schedule]);
|
|
476
|
+
return /* @__PURE__ */ jsx("div", {
|
|
477
|
+
className: "pin-layer",
|
|
478
|
+
children: placed.map(({ pin, x, y, strategy }) => /* @__PURE__ */ jsx("button", {
|
|
479
|
+
type: "button",
|
|
480
|
+
className: "pin",
|
|
481
|
+
"data-lane": pin.lane,
|
|
482
|
+
"data-moved": strategy === "exact" ? void 0 : "",
|
|
483
|
+
"aria-pressed": activeId === pin._id,
|
|
484
|
+
style: {
|
|
485
|
+
left: x,
|
|
486
|
+
top: y
|
|
487
|
+
},
|
|
488
|
+
onClick: () => onSelect(activeId === pin._id ? null : pin._id),
|
|
489
|
+
title: strategy === "exact" ? `#${pin.number} ${pin.authorName}` : `#${pin.number} ${pin.authorName} — Position angenähert (${strategy})`,
|
|
490
|
+
children: pin.number
|
|
491
|
+
}, pin._id))
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
//#endregion
|
|
495
|
+
//#region src/review/route.ts
|
|
496
|
+
/**
|
|
497
|
+
* The route a pin belongs to.
|
|
498
|
+
*
|
|
499
|
+
* Locale-stripped on purpose: with `localePrefix: "as-needed"` the German page
|
|
500
|
+
* is `/standorte/wedel` and the English one `/en/standorte/wedel`. Storing the
|
|
501
|
+
* raw path would make those two different routes, so a pin left by a German
|
|
502
|
+
* reviewer would be invisible to anyone reading the English page — and the same
|
|
503
|
+
* layout bug would be reported once per language.
|
|
504
|
+
*/
|
|
505
|
+
function routeKey(pathname, locale) {
|
|
506
|
+
const stripped = pathname === `/${locale}` ? "/" : pathname.startsWith(`/${locale}/`) ? pathname.slice(locale.length + 1) : pathname;
|
|
507
|
+
return stripped.length > 1 ? stripped.replace(/\/+$/, "") : "/";
|
|
508
|
+
}
|
|
509
|
+
//#endregion
|
|
510
|
+
//#region src/review/session.ts
|
|
511
|
+
/**
|
|
512
|
+
* The reviewer's session on the reviewed site's own origin.
|
|
513
|
+
*
|
|
514
|
+
* The dashboard lives on a different origin with its own storage, so the same
|
|
515
|
+
* link is redeemed on both. The backend keys the reviewer on the grant, so that
|
|
516
|
+
* is one identity with two sessions rather than two people.
|
|
517
|
+
*/
|
|
518
|
+
const KEY = "hn-review:session";
|
|
519
|
+
const NAME_KEY = "hn-review:name";
|
|
520
|
+
function safeGet(key) {
|
|
521
|
+
try {
|
|
522
|
+
return localStorage.getItem(key);
|
|
523
|
+
} catch {
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
function safeSet(key, value) {
|
|
528
|
+
try {
|
|
529
|
+
localStorage.setItem(key, value);
|
|
530
|
+
} catch {}
|
|
531
|
+
}
|
|
532
|
+
function readToken(slug) {
|
|
533
|
+
return safeGet(`${KEY}:${slug}`);
|
|
534
|
+
}
|
|
535
|
+
function writeToken(slug, token) {
|
|
536
|
+
safeSet(`${KEY}:${slug}`, token);
|
|
537
|
+
}
|
|
538
|
+
function readName() {
|
|
539
|
+
return safeGet(NAME_KEY);
|
|
540
|
+
}
|
|
541
|
+
function writeName(name) {
|
|
542
|
+
safeSet(NAME_KEY, name);
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Pull a grant out of `?rv=` and strip it from the URL.
|
|
546
|
+
*
|
|
547
|
+
* Returns it once: the caller redeems it, and the parameter is gone from the
|
|
548
|
+
* address bar before it can be bookmarked, screenshotted or sent to anyone in a
|
|
549
|
+
* Referer header.
|
|
550
|
+
*/
|
|
551
|
+
function takeGrantFromUrl() {
|
|
552
|
+
if (typeof window === "undefined") return null;
|
|
553
|
+
const url = new URL(window.location.href);
|
|
554
|
+
const grant = url.searchParams.get("rv");
|
|
555
|
+
if (!grant) return null;
|
|
556
|
+
url.searchParams.delete("rv");
|
|
557
|
+
window.history.replaceState({}, "", url.toString());
|
|
558
|
+
return grant;
|
|
559
|
+
}
|
|
560
|
+
//#endregion
|
|
561
|
+
//#region src/review/review.tsx
|
|
562
|
+
const CSS = HUD_CSS + PICKER_CSS + `
|
|
563
|
+
.hud { --review-client: #2563eb; --review-internal: #9333ea; --review-moved: #fbbf24; }
|
|
564
|
+
|
|
565
|
+
/* Client sites fill every corner (booking CTA, chat launcher, cookie banner,
|
|
566
|
+
rating badge), so the review pill is a tab on the middle of the right edge
|
|
567
|
+
and rides alongside the docked sheet (inline \`right\`). */
|
|
568
|
+
.hud .review-pill {
|
|
569
|
+
top: 50%;
|
|
570
|
+
bottom: auto;
|
|
571
|
+
left: auto;
|
|
572
|
+
right: 0;
|
|
573
|
+
transform: translateY(-50%);
|
|
574
|
+
border-right: 0;
|
|
575
|
+
border-radius: 999px 0 0 999px;
|
|
576
|
+
font-family: var(--hud-font);
|
|
577
|
+
}
|
|
578
|
+
.review-pill .count {
|
|
579
|
+
min-width: 18px;
|
|
580
|
+
padding: 0 5px;
|
|
581
|
+
border-radius: 999px;
|
|
582
|
+
background: var(--hud-accent);
|
|
583
|
+
color: var(--hud-accent-fg);
|
|
584
|
+
font: 600 10px/16px var(--hud-mono);
|
|
585
|
+
text-align: center;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/* The German hint is long; let it wrap on a phone instead of running off both edges. */
|
|
589
|
+
.grab-hint {
|
|
590
|
+
width: max-content;
|
|
591
|
+
max-width: calc(100vw - 16px);
|
|
592
|
+
white-space: normal;
|
|
593
|
+
text-align: center;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
.pin-layer { position: fixed; inset: 0; pointer-events: none; }
|
|
597
|
+
.hud .pin {
|
|
598
|
+
position: fixed;
|
|
599
|
+
transform: translate(-50%, -50%);
|
|
600
|
+
width: 26px;
|
|
601
|
+
height: 26px;
|
|
602
|
+
border-radius: 50%;
|
|
603
|
+
border: 2px solid #ffffff;
|
|
604
|
+
background: var(--review-client);
|
|
605
|
+
color: #ffffff;
|
|
606
|
+
font: 700 12px/1 var(--hud-font);
|
|
607
|
+
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.35);
|
|
608
|
+
pointer-events: auto;
|
|
609
|
+
}
|
|
610
|
+
.hud .pin[data-lane="internal"] { background: var(--review-internal); }
|
|
611
|
+
.hud .pin[data-moved] { border-color: var(--review-moved); }
|
|
612
|
+
.hud .pin[aria-pressed="true"] { outline: 2px solid var(--hud-fg); outline-offset: 2px; }
|
|
613
|
+
|
|
614
|
+
.sheet {
|
|
615
|
+
position: fixed;
|
|
616
|
+
top: 0;
|
|
617
|
+
right: 0;
|
|
618
|
+
bottom: 0;
|
|
619
|
+
width: 100%;
|
|
620
|
+
overflow-y: auto;
|
|
621
|
+
padding: 16px;
|
|
622
|
+
background: var(--hud-bg);
|
|
623
|
+
color: var(--hud-fg);
|
|
624
|
+
border-left: 1px solid var(--hud-border);
|
|
625
|
+
box-shadow: var(--hud-shadow);
|
|
626
|
+
font-size: 13px;
|
|
627
|
+
pointer-events: auto;
|
|
628
|
+
}
|
|
629
|
+
.sheet-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
630
|
+
.sheet-head strong { font-size: 14px; }
|
|
631
|
+
.sheet-tools { display: flex; align-items: center; gap: 4px; }
|
|
632
|
+
.hud .toggle {
|
|
633
|
+
padding: 3px 6px;
|
|
634
|
+
border: 1px solid var(--hud-border);
|
|
635
|
+
border-radius: 5px;
|
|
636
|
+
color: var(--hud-fg-muted);
|
|
637
|
+
font-size: 10px;
|
|
638
|
+
font-weight: 600;
|
|
639
|
+
}
|
|
640
|
+
.hud .toggle[aria-pressed="true"] { background: var(--hud-fg); color: var(--hud-bg); }
|
|
641
|
+
.hud .close { padding: 0 4px; font-size: 16px; line-height: 1; }
|
|
642
|
+
.muted { margin: 4px 0 12px; color: var(--hud-fg-muted); font-size: 12px; }
|
|
643
|
+
|
|
644
|
+
.compose textarea {
|
|
645
|
+
display: block;
|
|
646
|
+
width: 100%;
|
|
647
|
+
padding: 8px;
|
|
648
|
+
border: 1px solid var(--hud-border);
|
|
649
|
+
border-radius: 6px;
|
|
650
|
+
background: var(--hud-bg);
|
|
651
|
+
color: var(--hud-fg);
|
|
652
|
+
font: inherit;
|
|
653
|
+
resize: vertical;
|
|
654
|
+
}
|
|
655
|
+
.actions { display: flex; gap: 8px; margin-top: 8px; }
|
|
656
|
+
.hud .primary, .hud .secondary {
|
|
657
|
+
padding: 8px 10px;
|
|
658
|
+
border-radius: 6px;
|
|
659
|
+
font-weight: 600;
|
|
660
|
+
}
|
|
661
|
+
.hud .primary { flex: 1; background: var(--hud-accent); color: var(--hud-accent-fg); }
|
|
662
|
+
.hud .primary:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
663
|
+
.hud .secondary { border: 1px solid var(--hud-border); }
|
|
664
|
+
.hud .start { width: 100%; padding: 10px; background: var(--hud-fg); color: var(--hud-bg); }
|
|
665
|
+
|
|
666
|
+
.pins { margin-top: 16px; }
|
|
667
|
+
.pins ul { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }
|
|
668
|
+
.pins li { padding: 8px; border: 1px solid var(--hud-border); border-radius: 6px; }
|
|
669
|
+
.pins li[data-active] { border-color: var(--hud-accent); }
|
|
670
|
+
.pins.unplaced li { border-style: dashed; }
|
|
671
|
+
.pins p { margin: 4px 0 0; }
|
|
672
|
+
`;
|
|
673
|
+
/**
|
|
674
|
+
* The production-safe review widget: a pill, the element picker and a docked
|
|
675
|
+
* review sheet with the page's pins.
|
|
676
|
+
*
|
|
677
|
+
* Everything arrives as a prop and nothing is read from `process.env`. That is
|
|
678
|
+
* what makes the runtime gate work: an app that ships one image to two
|
|
679
|
+
* productions has identical build-time flags on both, so a build flag could not
|
|
680
|
+
* switch the widget off where it must be off. The mounting app decides, per
|
|
681
|
+
* deployment, by passing `convexUrl` or not.
|
|
682
|
+
*
|
|
683
|
+
* Nothing renders, and no DOM is touched, until a reviewer session exists:
|
|
684
|
+
* a visitor without a review link never sees a pill.
|
|
685
|
+
*/
|
|
686
|
+
function Review({ convexUrl, projectSlug, locale, backend }) {
|
|
687
|
+
const [client, setClient] = useState(null);
|
|
688
|
+
useEffect(() => {
|
|
689
|
+
if (backend) {
|
|
690
|
+
setClient(backend);
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
if (!convexUrl) {
|
|
694
|
+
setClient(null);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
const convex = convexReview(convexUrl);
|
|
698
|
+
setClient(convex);
|
|
699
|
+
return () => {
|
|
700
|
+
convex.close?.();
|
|
701
|
+
setClient(null);
|
|
702
|
+
};
|
|
703
|
+
}, [backend, convexUrl]);
|
|
704
|
+
if (!client) return null;
|
|
705
|
+
return /* @__PURE__ */ jsx(Widget, {
|
|
706
|
+
backend: client,
|
|
707
|
+
projectSlug,
|
|
708
|
+
locale
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
function Widget({ backend, projectSlug, locale }) {
|
|
712
|
+
const [token, setToken] = useState(null);
|
|
713
|
+
const [ready, setReady] = useState(false);
|
|
714
|
+
const [mode, setMode] = useState("idle");
|
|
715
|
+
const [draft, setDraft] = useState(null);
|
|
716
|
+
const [body, setBody] = useState("");
|
|
717
|
+
const [name, setName] = useState("");
|
|
718
|
+
const [failed, setFailed] = useState(false);
|
|
719
|
+
const [activeId, setActiveId] = useState(null);
|
|
720
|
+
const [unplaced, setUnplaced] = useState([]);
|
|
721
|
+
const [open, setOpen] = useState(false);
|
|
722
|
+
const [route, setRoute] = useState("/");
|
|
723
|
+
useEffect(() => {
|
|
724
|
+
setRoute(routeKey(window.location.pathname, locale));
|
|
725
|
+
setName(readName() ?? "");
|
|
726
|
+
const existing = readToken(projectSlug);
|
|
727
|
+
const grant = takeGrantFromUrl();
|
|
728
|
+
if (!grant) {
|
|
729
|
+
setToken(existing);
|
|
730
|
+
setReady(true);
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
backend.redeemGrant(grant).then((session) => {
|
|
734
|
+
writeToken(session.projectSlug, session.sessionToken);
|
|
735
|
+
writeName(session.displayName);
|
|
736
|
+
setToken(session.sessionToken);
|
|
737
|
+
setName(session.displayName);
|
|
738
|
+
}).catch(() => setToken(existing)).finally(() => setReady(true));
|
|
739
|
+
}, [
|
|
740
|
+
backend,
|
|
741
|
+
projectSlug,
|
|
742
|
+
locale
|
|
743
|
+
]);
|
|
744
|
+
useEffect(() => {
|
|
745
|
+
const id = setInterval(() => {
|
|
746
|
+
const next = routeKey(window.location.pathname, locale);
|
|
747
|
+
setRoute((current) => current === next ? current : next);
|
|
748
|
+
}, 400);
|
|
749
|
+
return () => clearInterval(id);
|
|
750
|
+
}, [locale]);
|
|
751
|
+
const [reviewer, setReviewer] = useState(void 0);
|
|
752
|
+
useEffect(() => {
|
|
753
|
+
setReviewer(void 0);
|
|
754
|
+
if (!token) return;
|
|
755
|
+
return backend.watchReviewer(token, setReviewer);
|
|
756
|
+
}, [backend, token]);
|
|
757
|
+
const [pins, setPins] = useState([]);
|
|
758
|
+
useEffect(() => {
|
|
759
|
+
setPins([]);
|
|
760
|
+
if (!token) return;
|
|
761
|
+
return backend.watchPins(token, route, setPins);
|
|
762
|
+
}, [
|
|
763
|
+
backend,
|
|
764
|
+
token,
|
|
765
|
+
route
|
|
766
|
+
]);
|
|
767
|
+
const [dockWidth, setDockWidth] = useState(0);
|
|
768
|
+
useEffect(() => {
|
|
769
|
+
const measure = () => setDockWidth(dockWidthFor(window.innerWidth));
|
|
770
|
+
measure();
|
|
771
|
+
window.addEventListener("resize", measure);
|
|
772
|
+
return () => window.removeEventListener("resize", measure);
|
|
773
|
+
}, []);
|
|
774
|
+
const [inset, setInset] = useState(false);
|
|
775
|
+
useEffect(() => setInset(readInsetPref()), []);
|
|
776
|
+
useEffect(() => {
|
|
777
|
+
applyPageInset(open && inset ? dockWidth : 0);
|
|
778
|
+
return () => applyPageInset(0);
|
|
779
|
+
}, [
|
|
780
|
+
open,
|
|
781
|
+
dockWidth,
|
|
782
|
+
inset
|
|
783
|
+
]);
|
|
784
|
+
const onPick = useCallback((element, point) => {
|
|
785
|
+
setDraft(capturePinAnchor(element, point));
|
|
786
|
+
setMode("composing");
|
|
787
|
+
setFailed(false);
|
|
788
|
+
setOpen(true);
|
|
789
|
+
}, []);
|
|
790
|
+
const onSelect = useCallback((id) => {
|
|
791
|
+
setActiveId(id);
|
|
792
|
+
if (id) setOpen(true);
|
|
793
|
+
}, []);
|
|
794
|
+
const submit = useCallback(async () => {
|
|
795
|
+
if (!token || !draft || !body.trim()) return;
|
|
796
|
+
try {
|
|
797
|
+
await backend.plant({
|
|
798
|
+
sessionToken: token,
|
|
799
|
+
route,
|
|
800
|
+
rawPath: window.location.pathname,
|
|
801
|
+
locale,
|
|
802
|
+
body,
|
|
803
|
+
anchor: draft
|
|
804
|
+
});
|
|
805
|
+
} catch {
|
|
806
|
+
setFailed(true);
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
setBody("");
|
|
810
|
+
setDraft(null);
|
|
811
|
+
setFailed(false);
|
|
812
|
+
setMode("idle");
|
|
813
|
+
}, [
|
|
814
|
+
backend,
|
|
815
|
+
token,
|
|
816
|
+
draft,
|
|
817
|
+
body,
|
|
818
|
+
route,
|
|
819
|
+
locale
|
|
820
|
+
]);
|
|
821
|
+
const discard = useCallback(() => {
|
|
822
|
+
setDraft(null);
|
|
823
|
+
setBody("");
|
|
824
|
+
setFailed(false);
|
|
825
|
+
setMode("idle");
|
|
826
|
+
}, []);
|
|
827
|
+
if (!ready || !token || reviewer === null) return null;
|
|
828
|
+
const docked = open ? dockWidth : 0;
|
|
829
|
+
const startPick = () => {
|
|
830
|
+
setMode("picking");
|
|
831
|
+
if (dockWidth === 0) setOpen(false);
|
|
832
|
+
};
|
|
833
|
+
const pickToggle = () => mode === "picking" ? setMode("idle") : startPick();
|
|
834
|
+
return /* @__PURE__ */ jsxs(ShadowHost, {
|
|
835
|
+
css: CSS,
|
|
836
|
+
marker: HUD_REVIEW_MARKER,
|
|
837
|
+
children: [
|
|
838
|
+
/* @__PURE__ */ jsx(PinLayer, {
|
|
839
|
+
activeId,
|
|
840
|
+
onSelect,
|
|
841
|
+
onUnplaced: setUnplaced,
|
|
842
|
+
pins
|
|
843
|
+
}),
|
|
844
|
+
/* @__PURE__ */ jsx(ElementPicker, {
|
|
845
|
+
active: mode === "picking",
|
|
846
|
+
hint: "Element anklicken, das du kommentieren willst · Esc bricht ab",
|
|
847
|
+
onCancel: () => setMode("idle"),
|
|
848
|
+
onPick
|
|
849
|
+
}),
|
|
850
|
+
open && /* @__PURE__ */ jsx(Sheet, {
|
|
851
|
+
width: dockWidth,
|
|
852
|
+
mode,
|
|
853
|
+
draft,
|
|
854
|
+
body,
|
|
855
|
+
failed,
|
|
856
|
+
name: name || reviewer?.displayName || "Gast",
|
|
857
|
+
pins,
|
|
858
|
+
unplaced,
|
|
859
|
+
activeId,
|
|
860
|
+
inset,
|
|
861
|
+
onBody: setBody,
|
|
862
|
+
onStartPick: startPick,
|
|
863
|
+
onSubmit: submit,
|
|
864
|
+
onDiscard: discard,
|
|
865
|
+
onClose: () => setOpen(false),
|
|
866
|
+
onToggleInset: () => setInset((v) => {
|
|
867
|
+
writeInsetPref(!v);
|
|
868
|
+
return !v;
|
|
869
|
+
})
|
|
870
|
+
}),
|
|
871
|
+
/* @__PURE__ */ jsxs("div", {
|
|
872
|
+
className: "pill review-pill",
|
|
873
|
+
role: "toolbar",
|
|
874
|
+
"aria-label": "Review",
|
|
875
|
+
style: { right: docked },
|
|
876
|
+
children: [/* @__PURE__ */ jsxs("button", {
|
|
877
|
+
type: "button",
|
|
878
|
+
className: "pill-item",
|
|
879
|
+
"aria-pressed": open,
|
|
880
|
+
onClick: () => setOpen((v) => !v),
|
|
881
|
+
children: ["Review", pins.length > 0 && /* @__PURE__ */ jsx("span", {
|
|
882
|
+
className: "count",
|
|
883
|
+
children: pins.length
|
|
884
|
+
})]
|
|
885
|
+
}), /* @__PURE__ */ jsx("button", {
|
|
886
|
+
type: "button",
|
|
887
|
+
className: "pill-item",
|
|
888
|
+
"aria-label": "Element kommentieren",
|
|
889
|
+
"aria-pressed": mode === "picking",
|
|
890
|
+
title: "Element kommentieren",
|
|
891
|
+
onClick: pickToggle,
|
|
892
|
+
children: "⌖"
|
|
893
|
+
})]
|
|
894
|
+
})
|
|
895
|
+
]
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
function Sheet({ width, mode, draft, body, failed, name, pins, unplaced, activeId, inset, onBody, onStartPick, onSubmit, onDiscard, onClose, onToggleInset }) {
|
|
899
|
+
return /* @__PURE__ */ jsxs("aside", {
|
|
900
|
+
className: "sheet",
|
|
901
|
+
"aria-label": "Review",
|
|
902
|
+
style: width > 0 ? { width } : void 0,
|
|
903
|
+
children: [
|
|
904
|
+
/* @__PURE__ */ jsxs("header", {
|
|
905
|
+
className: "sheet-head",
|
|
906
|
+
children: [/* @__PURE__ */ jsx("strong", { children: "Review" }), /* @__PURE__ */ jsxs("span", {
|
|
907
|
+
className: "sheet-tools",
|
|
908
|
+
children: [width > 0 && /* @__PURE__ */ jsx("button", {
|
|
909
|
+
type: "button",
|
|
910
|
+
className: "toggle",
|
|
911
|
+
"aria-pressed": inset,
|
|
912
|
+
onClick: onToggleInset,
|
|
913
|
+
title: inset ? "Seite wieder voll breit zeigen" : "Seite schmaler machen. Achtung: die Seite rechnet weiter mit der vollen Fensterbreite, das Layout kann dadurch anders aussehen als beim Kunden.",
|
|
914
|
+
children: "schmaler"
|
|
915
|
+
}), /* @__PURE__ */ jsx("button", {
|
|
916
|
+
type: "button",
|
|
917
|
+
className: "close",
|
|
918
|
+
"aria-label": "Schließen",
|
|
919
|
+
onClick: onClose,
|
|
920
|
+
children: "×"
|
|
921
|
+
})]
|
|
922
|
+
})]
|
|
923
|
+
}),
|
|
924
|
+
/* @__PURE__ */ jsxs("p", {
|
|
925
|
+
className: "muted",
|
|
926
|
+
children: ["Angemeldet als ", name]
|
|
927
|
+
}),
|
|
928
|
+
mode === "composing" && draft ? /* @__PURE__ */ jsxs("div", {
|
|
929
|
+
className: "compose",
|
|
930
|
+
children: [
|
|
931
|
+
/* @__PURE__ */ jsx("p", {
|
|
932
|
+
className: "muted",
|
|
933
|
+
children: draft.label
|
|
934
|
+
}),
|
|
935
|
+
/* @__PURE__ */ jsx("textarea", {
|
|
936
|
+
"aria-label": "Kommentar",
|
|
937
|
+
placeholder: "Was stimmt hier nicht?",
|
|
938
|
+
rows: 4,
|
|
939
|
+
value: body,
|
|
940
|
+
onChange: (event) => onBody(event.target.value)
|
|
941
|
+
}),
|
|
942
|
+
failed && /* @__PURE__ */ jsx("p", {
|
|
943
|
+
className: "muted",
|
|
944
|
+
children: "Speichern fehlgeschlagen. Bitte noch einmal versuchen."
|
|
945
|
+
}),
|
|
946
|
+
/* @__PURE__ */ jsxs("div", {
|
|
947
|
+
className: "actions",
|
|
948
|
+
children: [/* @__PURE__ */ jsx("button", {
|
|
949
|
+
type: "button",
|
|
950
|
+
className: "primary",
|
|
951
|
+
disabled: !body.trim(),
|
|
952
|
+
onClick: onSubmit,
|
|
953
|
+
children: "Pin setzen"
|
|
954
|
+
}), /* @__PURE__ */ jsx("button", {
|
|
955
|
+
type: "button",
|
|
956
|
+
className: "secondary",
|
|
957
|
+
onClick: onDiscard,
|
|
958
|
+
children: "Verwerfen"
|
|
959
|
+
})]
|
|
960
|
+
})
|
|
961
|
+
]
|
|
962
|
+
}) : /* @__PURE__ */ jsx("button", {
|
|
963
|
+
type: "button",
|
|
964
|
+
className: "primary start",
|
|
965
|
+
onClick: onStartPick,
|
|
966
|
+
children: "Element kommentieren"
|
|
967
|
+
}),
|
|
968
|
+
/* @__PURE__ */ jsxs("section", {
|
|
969
|
+
className: "pins",
|
|
970
|
+
children: [/* @__PURE__ */ jsxs("p", {
|
|
971
|
+
className: "muted",
|
|
972
|
+
children: [
|
|
973
|
+
pins.length,
|
|
974
|
+
" Pin",
|
|
975
|
+
pins.length === 1 ? "" : "s",
|
|
976
|
+
" auf dieser Seite"
|
|
977
|
+
]
|
|
978
|
+
}), /* @__PURE__ */ jsx("ul", { children: pins.map((pin) => /* @__PURE__ */ jsxs("li", {
|
|
979
|
+
"data-active": activeId === pin._id ? "" : void 0,
|
|
980
|
+
children: [
|
|
981
|
+
/* @__PURE__ */ jsxs("strong", { children: ["#", pin.number] }),
|
|
982
|
+
" ",
|
|
983
|
+
pin.authorName,
|
|
984
|
+
/* @__PURE__ */ jsx("p", { children: pin.body })
|
|
985
|
+
]
|
|
986
|
+
}, pin._id)) })]
|
|
987
|
+
}),
|
|
988
|
+
unplaced.length > 0 && /* @__PURE__ */ jsxs("section", {
|
|
989
|
+
className: "pins unplaced",
|
|
990
|
+
children: [
|
|
991
|
+
/* @__PURE__ */ jsxs("p", {
|
|
992
|
+
className: "muted",
|
|
993
|
+
children: [
|
|
994
|
+
"Ohne Position (",
|
|
995
|
+
unplaced.length,
|
|
996
|
+
")"
|
|
997
|
+
]
|
|
998
|
+
}),
|
|
999
|
+
/* @__PURE__ */ jsx("p", {
|
|
1000
|
+
className: "muted",
|
|
1001
|
+
children: "Entweder in einer anderen Ansichtsbreite gesetzt, oder das Element gibt es nicht mehr."
|
|
1002
|
+
}),
|
|
1003
|
+
/* @__PURE__ */ jsx("ul", { children: unplaced.map((pin) => /* @__PURE__ */ jsxs("li", { children: [
|
|
1004
|
+
/* @__PURE__ */ jsxs("strong", { children: ["#", pin.number] }),
|
|
1005
|
+
" ",
|
|
1006
|
+
pin.authorName,
|
|
1007
|
+
" · ",
|
|
1008
|
+
BUCKET_LABEL[pin.viewportBucket],
|
|
1009
|
+
/* @__PURE__ */ jsx("p", { children: pin.body })
|
|
1010
|
+
] }, pin._id)) })
|
|
1011
|
+
]
|
|
1012
|
+
})
|
|
1013
|
+
]
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
//#endregion
|
|
1017
|
+
export { HUD_REVIEW_MARKER, Review, capturePinAnchor, convexReview, resolveAnchor, routeKey, viewportBucket };
|