@ldlework/feedback 0.1.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/LICENSE +21 -0
- package/README.md +227 -0
- package/dist/index.d.ts +190 -0
- package/dist/index.js +1370 -0
- package/dist/index.js.map +1 -0
- package/package.json +63 -0
- package/styles.css +478 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1370 @@
|
|
|
1
|
+
import { createContext, forwardRef, useMemo, useState, useRef, useCallback, useEffect, useContext, useLayoutEffect } from 'react';
|
|
2
|
+
import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
|
|
3
|
+
import { createPortal } from 'react-dom';
|
|
4
|
+
|
|
5
|
+
// src/core/context.tsx
|
|
6
|
+
|
|
7
|
+
// src/core/geometry.ts
|
|
8
|
+
var NOTE_WIDTH = 224;
|
|
9
|
+
var NOTE_MIN_HEIGHT = 132;
|
|
10
|
+
var PORTS = ["left", "bottom", "right"];
|
|
11
|
+
function knobPoint(note, port, height) {
|
|
12
|
+
switch (port) {
|
|
13
|
+
case "left":
|
|
14
|
+
return { x: note.x, y: note.y + height / 2 };
|
|
15
|
+
case "right":
|
|
16
|
+
return { x: note.x + NOTE_WIDTH, y: note.y + height / 2 };
|
|
17
|
+
case "bottom":
|
|
18
|
+
return { x: note.x + NOTE_WIDTH / 2, y: note.y + height };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
var PAD = 14;
|
|
22
|
+
var ARROW_LEN = 12;
|
|
23
|
+
var ARROW_HALF = 6.5;
|
|
24
|
+
function arrowGeometry(start, head) {
|
|
25
|
+
const origin = { x: Math.min(start.x, head.x) - PAD, y: Math.min(start.y, head.y) - PAD };
|
|
26
|
+
const s = { x: start.x - origin.x, y: start.y - origin.y };
|
|
27
|
+
const h = { x: head.x - origin.x, y: head.y - origin.y };
|
|
28
|
+
const len = Math.hypot(h.x - s.x, h.y - s.y) || 1;
|
|
29
|
+
const ux = (h.x - s.x) / len;
|
|
30
|
+
const uy = (h.y - s.y) / len;
|
|
31
|
+
const base = { x: h.x - ux * ARROW_LEN, y: h.y - uy * ARROW_LEN };
|
|
32
|
+
return {
|
|
33
|
+
origin,
|
|
34
|
+
width: Math.abs(head.x - start.x) + PAD * 2,
|
|
35
|
+
height: Math.abs(head.y - start.y) + PAD * 2,
|
|
36
|
+
start: s,
|
|
37
|
+
base,
|
|
38
|
+
head: h,
|
|
39
|
+
wings: [
|
|
40
|
+
{ x: base.x - uy * ARROW_HALF, y: base.y + ux * ARROW_HALF },
|
|
41
|
+
{ x: base.x + uy * ARROW_HALF, y: base.y - ux * ARROW_HALF }
|
|
42
|
+
]
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// src/core/document.ts
|
|
47
|
+
var EMPTY_DOC = { notes: [], edges: [] };
|
|
48
|
+
function normalizeDoc(data) {
|
|
49
|
+
const notes = normalizeNotes(data);
|
|
50
|
+
const edges = normalizeEdges(isRecord(data) ? data.edges : void 0).filter(
|
|
51
|
+
(edge) => notes.some((note) => note.id === edge.noteId)
|
|
52
|
+
);
|
|
53
|
+
return { notes, edges };
|
|
54
|
+
}
|
|
55
|
+
function normalizeNotes(data) {
|
|
56
|
+
const raw = Array.isArray(data) ? data : isRecord(data) && Array.isArray(data.notes) ? data.notes : [];
|
|
57
|
+
return raw.filter(isRecord).map(toNote);
|
|
58
|
+
}
|
|
59
|
+
function normalizeEdges(data) {
|
|
60
|
+
return Array.isArray(data) ? data.filter(isRecord).map(toEdge) : [];
|
|
61
|
+
}
|
|
62
|
+
function isRecord(value) {
|
|
63
|
+
return typeof value === "object" && value !== null;
|
|
64
|
+
}
|
|
65
|
+
function toNote(r) {
|
|
66
|
+
return {
|
|
67
|
+
id: typeof r.id === "string" ? r.id : uid(),
|
|
68
|
+
page: typeof r.page === "string" ? r.page : "/",
|
|
69
|
+
section: typeof r.section === "string" ? r.section : null,
|
|
70
|
+
x: Number(r.x) || 0,
|
|
71
|
+
y: Number(r.y) || 0,
|
|
72
|
+
text: typeof r.text === "string" ? r.text : "",
|
|
73
|
+
resolved: r.resolved === true
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function toEdge(r) {
|
|
77
|
+
const port = r.port === "left" || r.port === "bottom" || r.port === "right" ? r.port : "right";
|
|
78
|
+
return {
|
|
79
|
+
id: typeof r.id === "string" ? r.id : uid(),
|
|
80
|
+
page: typeof r.page === "string" ? r.page : "/",
|
|
81
|
+
section: typeof r.section === "string" ? r.section : null,
|
|
82
|
+
noteId: typeof r.noteId === "string" ? r.noteId : "",
|
|
83
|
+
port,
|
|
84
|
+
x: Number(r.x) || 0,
|
|
85
|
+
y: Number(r.y) || 0,
|
|
86
|
+
target: toTarget(r.target)
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function toTarget(data) {
|
|
90
|
+
if (!isRecord(data) || typeof data.selector !== "string") return null;
|
|
91
|
+
return { selector: data.selector, text: typeof data.text === "string" ? data.text : null };
|
|
92
|
+
}
|
|
93
|
+
function uid() {
|
|
94
|
+
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
|
|
95
|
+
return `fb-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/core/persistence.ts
|
|
99
|
+
function readDoc(storageKey, seed = EMPTY_DOC) {
|
|
100
|
+
if (typeof window === "undefined") return seed;
|
|
101
|
+
const stored = window.localStorage.getItem(storageKey);
|
|
102
|
+
if (stored === null) return seed;
|
|
103
|
+
try {
|
|
104
|
+
return normalizeDoc(JSON.parse(stored));
|
|
105
|
+
} catch {
|
|
106
|
+
return seed;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function writeDoc(storageKey, doc) {
|
|
110
|
+
if (typeof window === "undefined") return;
|
|
111
|
+
try {
|
|
112
|
+
window.localStorage.setItem(storageKey, JSON.stringify(doc));
|
|
113
|
+
} catch (err) {
|
|
114
|
+
console.error("[feedback] failed to persist", err);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async function exportDoc(doc, fileName) {
|
|
118
|
+
const payload = JSON.stringify({ exportedAt: (/* @__PURE__ */ new Date()).toISOString(), ...doc }, null, 2);
|
|
119
|
+
if (await saveViaPicker(payload, fileName)) return;
|
|
120
|
+
saveViaDownload(payload, fileName);
|
|
121
|
+
}
|
|
122
|
+
async function importDoc() {
|
|
123
|
+
const file = await pickFile();
|
|
124
|
+
if (!file) return null;
|
|
125
|
+
try {
|
|
126
|
+
return normalizeDoc(JSON.parse(await file.text()));
|
|
127
|
+
} catch {
|
|
128
|
+
return EMPTY_DOC;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
var JSON_TYPES = [{ description: "JSON", accept: { "application/json": [".json"] } }];
|
|
132
|
+
async function saveViaPicker(text, fileName) {
|
|
133
|
+
const picker = window.showSaveFilePicker;
|
|
134
|
+
if (typeof picker !== "function") return false;
|
|
135
|
+
try {
|
|
136
|
+
const handle = await picker({ suggestedName: fileName, types: JSON_TYPES });
|
|
137
|
+
const writable = await handle.createWritable();
|
|
138
|
+
await writable.write(text);
|
|
139
|
+
await writable.close();
|
|
140
|
+
return true;
|
|
141
|
+
} catch (err) {
|
|
142
|
+
if (err instanceof DOMException && err.name === "AbortError") return true;
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function saveViaDownload(text, fileName) {
|
|
147
|
+
const url = URL.createObjectURL(new Blob([text], { type: "application/json" }));
|
|
148
|
+
const anchor = document.createElement("a");
|
|
149
|
+
anchor.href = url;
|
|
150
|
+
anchor.download = fileName;
|
|
151
|
+
anchor.click();
|
|
152
|
+
URL.revokeObjectURL(url);
|
|
153
|
+
}
|
|
154
|
+
async function pickFile() {
|
|
155
|
+
const picker = window.showOpenFilePicker;
|
|
156
|
+
if (typeof picker === "function") {
|
|
157
|
+
try {
|
|
158
|
+
const [handle] = await picker({ multiple: false, types: JSON_TYPES });
|
|
159
|
+
return handle ? await handle.getFile() : null;
|
|
160
|
+
} catch (err) {
|
|
161
|
+
if (err instanceof DOMException && err.name === "AbortError") return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return pickViaInput();
|
|
165
|
+
}
|
|
166
|
+
function pickViaInput() {
|
|
167
|
+
return new Promise((resolve) => {
|
|
168
|
+
const input = document.createElement("input");
|
|
169
|
+
input.type = "file";
|
|
170
|
+
input.accept = "application/json,.json";
|
|
171
|
+
input.addEventListener("change", () => resolve(input.files?.[0] ?? null), { once: true });
|
|
172
|
+
input.click();
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// src/core/useFeedbackDoc.ts
|
|
177
|
+
function useFeedbackDoc(storageKey, seed) {
|
|
178
|
+
const [doc, setDoc] = useState(
|
|
179
|
+
() => readDoc(storageKey, seed ? normalizeDoc(seed) : EMPTY_DOC)
|
|
180
|
+
);
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
writeDoc(storageKey, doc);
|
|
183
|
+
}, [storageKey, doc]);
|
|
184
|
+
const addNote = useCallback((page, section, x, y) => {
|
|
185
|
+
const note = { id: uid(), page, section, x, y, text: "", resolved: false };
|
|
186
|
+
setDoc((d) => ({ ...d, notes: [...d.notes, note] }));
|
|
187
|
+
return note.id;
|
|
188
|
+
}, []);
|
|
189
|
+
const updateNote = useCallback((id, patch) => {
|
|
190
|
+
setDoc((d) => ({ ...d, notes: d.notes.map((n) => n.id === id ? { ...n, ...patch } : n) }));
|
|
191
|
+
}, []);
|
|
192
|
+
const removeNote = useCallback((id) => {
|
|
193
|
+
setDoc((d) => ({
|
|
194
|
+
notes: d.notes.filter((n) => n.id !== id),
|
|
195
|
+
edges: d.edges.filter((e) => e.noteId !== id)
|
|
196
|
+
}));
|
|
197
|
+
}, []);
|
|
198
|
+
const addEdge = useCallback((edge) => {
|
|
199
|
+
setDoc((d) => ({ ...d, edges: [...d.edges, { ...edge, id: uid() }] }));
|
|
200
|
+
}, []);
|
|
201
|
+
const updateEdge = useCallback((id, patch) => {
|
|
202
|
+
setDoc((d) => ({ ...d, edges: d.edges.map((e) => e.id === id ? { ...e, ...patch } : e) }));
|
|
203
|
+
}, []);
|
|
204
|
+
const removeEdge = useCallback((id) => {
|
|
205
|
+
setDoc((d) => ({ ...d, edges: d.edges.filter((e) => e.id !== id) }));
|
|
206
|
+
}, []);
|
|
207
|
+
const replace = useCallback((next) => setDoc(next), []);
|
|
208
|
+
return {
|
|
209
|
+
notes: doc.notes,
|
|
210
|
+
edges: doc.edges,
|
|
211
|
+
addNote,
|
|
212
|
+
updateNote,
|
|
213
|
+
removeNote,
|
|
214
|
+
addEdge,
|
|
215
|
+
updateEdge,
|
|
216
|
+
removeEdge,
|
|
217
|
+
replace
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
function useLocationNavigation() {
|
|
221
|
+
const [page, setPage] = useState(() => typeof location === "undefined" ? "/" : location.pathname);
|
|
222
|
+
useEffect(() => {
|
|
223
|
+
const sync = () => setPage(location.pathname);
|
|
224
|
+
window.addEventListener("popstate", sync);
|
|
225
|
+
return () => window.removeEventListener("popstate", sync);
|
|
226
|
+
}, []);
|
|
227
|
+
return useMemo(
|
|
228
|
+
() => ({
|
|
229
|
+
page,
|
|
230
|
+
navigate: (to) => {
|
|
231
|
+
if (to === location.pathname) return;
|
|
232
|
+
history.pushState(null, "", to);
|
|
233
|
+
setPage(to);
|
|
234
|
+
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
235
|
+
}
|
|
236
|
+
}),
|
|
237
|
+
[page]
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// src/core/submission.ts
|
|
242
|
+
function endpointSubmitter(url, options = {}) {
|
|
243
|
+
return async (doc) => {
|
|
244
|
+
const response = await fetch(url, {
|
|
245
|
+
method: options.method ?? "POST",
|
|
246
|
+
credentials: options.credentials,
|
|
247
|
+
headers: { "Content-Type": "application/json", ...options.headers },
|
|
248
|
+
body: JSON.stringify({ submittedAt: (/* @__PURE__ */ new Date()).toISOString(), ...doc })
|
|
249
|
+
});
|
|
250
|
+
if (!response.ok) {
|
|
251
|
+
throw new Error(`feedback submission failed: ${response.status} ${response.statusText}`);
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
function resolveSubmitter(submit) {
|
|
256
|
+
if (submit == null) return null;
|
|
257
|
+
return typeof submit === "string" ? endpointSubmitter(submit) : submit;
|
|
258
|
+
}
|
|
259
|
+
var FeedbackContext = createContext(null);
|
|
260
|
+
function FeedbackProvider({
|
|
261
|
+
children,
|
|
262
|
+
storageKey = "feedback",
|
|
263
|
+
fileName = "feedback.json",
|
|
264
|
+
navigation,
|
|
265
|
+
defaultAnchorX = "center",
|
|
266
|
+
className,
|
|
267
|
+
style,
|
|
268
|
+
initialDoc,
|
|
269
|
+
submit
|
|
270
|
+
}) {
|
|
271
|
+
const doc = useFeedbackDoc(storageKey, initialDoc);
|
|
272
|
+
const submitter = useMemo(() => resolveSubmitter(submit), [submit]);
|
|
273
|
+
const fallbackNav = useLocationNavigation();
|
|
274
|
+
const nav = navigation ?? fallbackNav;
|
|
275
|
+
const [binding, setBinding] = useState(null);
|
|
276
|
+
const [heights, setHeights] = useState({});
|
|
277
|
+
const pending = useRef(null);
|
|
278
|
+
const bindRegion = useCallback((next) => {
|
|
279
|
+
setBinding(next);
|
|
280
|
+
if (next?.reveal && pending.current != null) {
|
|
281
|
+
next.reveal(pending.current);
|
|
282
|
+
pending.current = null;
|
|
283
|
+
}
|
|
284
|
+
}, []);
|
|
285
|
+
const requestSection = useCallback((section) => {
|
|
286
|
+
pending.current = section;
|
|
287
|
+
}, []);
|
|
288
|
+
const reportHeight = useCallback((id, height) => {
|
|
289
|
+
setHeights((h) => h[id] === height ? h : { ...h, [id]: height });
|
|
290
|
+
}, []);
|
|
291
|
+
const clearHeight = useCallback((id) => {
|
|
292
|
+
setHeights((h) => {
|
|
293
|
+
if (!(id in h)) return h;
|
|
294
|
+
const { [id]: _drop, ...rest } = h;
|
|
295
|
+
return rest;
|
|
296
|
+
});
|
|
297
|
+
}, []);
|
|
298
|
+
const heightOf = useCallback((id) => heights[id] ?? NOTE_MIN_HEIGHT, [heights]);
|
|
299
|
+
const region = useMemo(
|
|
300
|
+
() => ({ element: binding?.element ?? null, anchorX: binding?.anchorX ?? defaultAnchorX }),
|
|
301
|
+
[binding?.element, binding?.anchorX, defaultAnchorX]
|
|
302
|
+
);
|
|
303
|
+
const value = useMemo(
|
|
304
|
+
() => ({
|
|
305
|
+
doc,
|
|
306
|
+
nav,
|
|
307
|
+
region,
|
|
308
|
+
section: binding?.section ?? null,
|
|
309
|
+
reveal: binding?.reveal ?? (() => {
|
|
310
|
+
}),
|
|
311
|
+
bindRegion,
|
|
312
|
+
requestSection,
|
|
313
|
+
heightOf,
|
|
314
|
+
reportHeight,
|
|
315
|
+
clearHeight,
|
|
316
|
+
fileName,
|
|
317
|
+
submit: submitter,
|
|
318
|
+
rootClassName: className ? `fb-root ${className}` : "fb-root",
|
|
319
|
+
rootStyle: style
|
|
320
|
+
}),
|
|
321
|
+
[doc, nav, region, binding, bindRegion, requestSection, heightOf, reportHeight, clearHeight, fileName, submitter, className, style]
|
|
322
|
+
);
|
|
323
|
+
return /* @__PURE__ */ jsx(FeedbackContext.Provider, { value, children });
|
|
324
|
+
}
|
|
325
|
+
function useFeedback() {
|
|
326
|
+
const ctx = useContext(FeedbackContext);
|
|
327
|
+
if (!ctx) throw new Error("useFeedback must be used within <FeedbackProvider>");
|
|
328
|
+
return ctx;
|
|
329
|
+
}
|
|
330
|
+
function contentWidth(region) {
|
|
331
|
+
return region.element?.clientWidth ?? document.documentElement.clientWidth;
|
|
332
|
+
}
|
|
333
|
+
function originX(region) {
|
|
334
|
+
const width = contentWidth(region);
|
|
335
|
+
switch (region.anchorX) {
|
|
336
|
+
case "left":
|
|
337
|
+
return 0;
|
|
338
|
+
case "center":
|
|
339
|
+
return width / 2;
|
|
340
|
+
case "right":
|
|
341
|
+
return width;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
function toContent(clientX, clientY, region) {
|
|
345
|
+
const origin = originX(region);
|
|
346
|
+
if (!region.element) {
|
|
347
|
+
return { x: clientX + window.scrollX - origin, y: clientY + window.scrollY };
|
|
348
|
+
}
|
|
349
|
+
const rect = region.element.getBoundingClientRect();
|
|
350
|
+
return {
|
|
351
|
+
x: clientX - rect.left + region.element.scrollLeft - origin,
|
|
352
|
+
y: clientY - rect.top + region.element.scrollTop
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
function useOriginX(region) {
|
|
356
|
+
const [origin, setOrigin] = useState(() => originX(region));
|
|
357
|
+
useEffect(() => {
|
|
358
|
+
const update = () => setOrigin(originX(region));
|
|
359
|
+
update();
|
|
360
|
+
window.addEventListener("resize", update);
|
|
361
|
+
const observer = region.element ? new ResizeObserver(update) : null;
|
|
362
|
+
if (region.element) observer?.observe(region.element);
|
|
363
|
+
return () => {
|
|
364
|
+
window.removeEventListener("resize", update);
|
|
365
|
+
observer?.disconnect();
|
|
366
|
+
};
|
|
367
|
+
}, [region.element, region.anchorX]);
|
|
368
|
+
return origin;
|
|
369
|
+
}
|
|
370
|
+
function useReview(notes) {
|
|
371
|
+
const { nav, reveal, requestSection, section } = useFeedback();
|
|
372
|
+
const [on, setOn] = useState(false);
|
|
373
|
+
const [includeResolved, setIncludeResolved] = useState(false);
|
|
374
|
+
const [index, setIndex] = useState(0);
|
|
375
|
+
const list = useMemo(() => {
|
|
376
|
+
const ordered = [...notes].sort(compareNotes);
|
|
377
|
+
return includeResolved ? ordered : ordered.filter((n) => !n.resolved);
|
|
378
|
+
}, [notes, includeResolved]);
|
|
379
|
+
const clamped = Math.min(index, Math.max(0, list.length - 1));
|
|
380
|
+
const current = list[clamped] ?? null;
|
|
381
|
+
useEffect(() => {
|
|
382
|
+
if (!on || !current) return;
|
|
383
|
+
revealNote(current, nav.page, section, nav.navigate, reveal, requestSection);
|
|
384
|
+
scrollToNote(current.id);
|
|
385
|
+
}, [on, current?.id]);
|
|
386
|
+
const go = useCallback(
|
|
387
|
+
(to) => setIndex(Math.min(Math.max(0, to), Math.max(0, list.length - 1))),
|
|
388
|
+
[list.length]
|
|
389
|
+
);
|
|
390
|
+
const toggle = useCallback(() => {
|
|
391
|
+
setOn((v) => !v);
|
|
392
|
+
setIndex(0);
|
|
393
|
+
}, []);
|
|
394
|
+
return {
|
|
395
|
+
on,
|
|
396
|
+
toggle,
|
|
397
|
+
includeResolved,
|
|
398
|
+
toggleIncludeResolved: useCallback(() => setIncludeResolved((v) => !v), []),
|
|
399
|
+
position: list.length ? clamped + 1 : 0,
|
|
400
|
+
total: list.length,
|
|
401
|
+
first: useCallback(() => go(0), [go]),
|
|
402
|
+
prev: useCallback(() => go(clamped - 1), [go, clamped]),
|
|
403
|
+
next: useCallback(() => go(clamped + 1), [go, clamped]),
|
|
404
|
+
last: useCallback(() => go(list.length - 1), [go, list.length]),
|
|
405
|
+
currentId: current?.id ?? null
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
function revealNote(note, page, section, navigate, reveal, requestSection) {
|
|
409
|
+
if (note.page !== page) {
|
|
410
|
+
requestSection(note.section);
|
|
411
|
+
navigate(note.page);
|
|
412
|
+
} else if (note.section != null && note.section !== section) {
|
|
413
|
+
reveal(note.section);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
function scrollToNote(id) {
|
|
417
|
+
let tries = 0;
|
|
418
|
+
const tick = () => {
|
|
419
|
+
const el = document.querySelector(`[data-note-id="${id}"]`);
|
|
420
|
+
if (el) {
|
|
421
|
+
el.scrollIntoView({ block: "center", inline: "center", behavior: "smooth" });
|
|
422
|
+
} else if (tries++ < 30) {
|
|
423
|
+
requestAnimationFrame(tick);
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
requestAnimationFrame(tick);
|
|
427
|
+
}
|
|
428
|
+
function compareNotes(a, b) {
|
|
429
|
+
if (a.page !== b.page) return a.page < b.page ? -1 : 1;
|
|
430
|
+
const sa = a.section ?? "";
|
|
431
|
+
const sb = b.section ?? "";
|
|
432
|
+
if (sa !== sb) return sa < sb ? -1 : 1;
|
|
433
|
+
if (a.y !== b.y) return a.y - b.y;
|
|
434
|
+
return a.x - b.x;
|
|
435
|
+
}
|
|
436
|
+
var SETTLE_MS = 2500;
|
|
437
|
+
function useSubmission(submitter) {
|
|
438
|
+
const [status, setStatus] = useState("idle");
|
|
439
|
+
const inFlight = useRef(false);
|
|
440
|
+
const timer = useRef(void 0);
|
|
441
|
+
useEffect(() => () => window.clearTimeout(timer.current), []);
|
|
442
|
+
const settle = useCallback((outcome) => {
|
|
443
|
+
setStatus(outcome);
|
|
444
|
+
window.clearTimeout(timer.current);
|
|
445
|
+
timer.current = window.setTimeout(() => setStatus("idle"), SETTLE_MS);
|
|
446
|
+
}, []);
|
|
447
|
+
const send = useCallback(
|
|
448
|
+
async (doc) => {
|
|
449
|
+
if (!submitter || inFlight.current) return;
|
|
450
|
+
inFlight.current = true;
|
|
451
|
+
window.clearTimeout(timer.current);
|
|
452
|
+
setStatus("sending");
|
|
453
|
+
try {
|
|
454
|
+
await submitter(doc);
|
|
455
|
+
settle("sent");
|
|
456
|
+
} catch (err) {
|
|
457
|
+
console.error("[feedback] submission failed", err);
|
|
458
|
+
settle("error");
|
|
459
|
+
} finally {
|
|
460
|
+
inFlight.current = false;
|
|
461
|
+
}
|
|
462
|
+
},
|
|
463
|
+
[submitter, settle]
|
|
464
|
+
);
|
|
465
|
+
return { status, send };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// src/core/targetAddress.ts
|
|
469
|
+
var MAX_DEPTH = 8;
|
|
470
|
+
var MAX_TEXT = 120;
|
|
471
|
+
function describeTarget(clientX, clientY) {
|
|
472
|
+
const el = topPageElement(clientX, clientY);
|
|
473
|
+
return el ? { selector: cssPath(el), text: excerpt(el) } : null;
|
|
474
|
+
}
|
|
475
|
+
function topPageElement(x, y) {
|
|
476
|
+
for (const el of document.elementsFromPoint(x, y)) {
|
|
477
|
+
if (!el.closest("[data-feedback]") && el !== document.documentElement && el !== document.body) {
|
|
478
|
+
return el;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
function cssPath(el) {
|
|
484
|
+
const parts = [];
|
|
485
|
+
let node = el;
|
|
486
|
+
while (node && node !== document.body && node !== document.documentElement && parts.length < MAX_DEPTH) {
|
|
487
|
+
parts.unshift(describeNode(node));
|
|
488
|
+
node = node.parentElement;
|
|
489
|
+
}
|
|
490
|
+
return parts.join(" > ");
|
|
491
|
+
}
|
|
492
|
+
function describeNode(el) {
|
|
493
|
+
const id = el.id ? `#${esc(el.id)}` : "";
|
|
494
|
+
const classes = Array.from(el.classList).map((c) => `.${esc(c)}`).join("");
|
|
495
|
+
return `${el.tagName.toLowerCase()}${id}${classes}${nthOfType(el)}`;
|
|
496
|
+
}
|
|
497
|
+
function nthOfType(el) {
|
|
498
|
+
const siblings = el.parentElement ? Array.from(el.parentElement.children).filter((c) => c.tagName === el.tagName) : [];
|
|
499
|
+
return siblings.length < 2 ? "" : `:nth-of-type(${siblings.indexOf(el) + 1})`;
|
|
500
|
+
}
|
|
501
|
+
function excerpt(el) {
|
|
502
|
+
const text = (el.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
503
|
+
if (!text) return null;
|
|
504
|
+
return text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT)}\u2026` : text;
|
|
505
|
+
}
|
|
506
|
+
function esc(value) {
|
|
507
|
+
return window.CSS?.escape ? window.CSS.escape(value) : value;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// src/hooks/useEdgeDraft.ts
|
|
511
|
+
var MIN_DRAG = 16;
|
|
512
|
+
function useEdgeDraft({ region, noteById, heightOf, onCommit }) {
|
|
513
|
+
const [draft, setDraft] = useState(null);
|
|
514
|
+
const live = useRef(null);
|
|
515
|
+
const set = (next) => {
|
|
516
|
+
live.current = next;
|
|
517
|
+
setDraft(next);
|
|
518
|
+
};
|
|
519
|
+
const start = (noteId, port) => {
|
|
520
|
+
const note = noteById(noteId);
|
|
521
|
+
if (note) set({ noteId, port, head: knobPoint(note, port, heightOf(noteId)) });
|
|
522
|
+
};
|
|
523
|
+
const move = (clientX, clientY) => {
|
|
524
|
+
const d = live.current;
|
|
525
|
+
if (d) set({ ...d, head: toContent(clientX, clientY, region) });
|
|
526
|
+
};
|
|
527
|
+
const end = (clientX, clientY) => {
|
|
528
|
+
const d = live.current;
|
|
529
|
+
const note = d && noteById(d.noteId);
|
|
530
|
+
if (d && note) {
|
|
531
|
+
const head = toContent(clientX, clientY, region);
|
|
532
|
+
const tail = knobPoint(note, d.port, heightOf(d.noteId));
|
|
533
|
+
if (Math.hypot(head.x - tail.x, head.y - tail.y) > MIN_DRAG) {
|
|
534
|
+
onCommit(d.noteId, d.port, head, describeTarget(clientX, clientY));
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
set(null);
|
|
538
|
+
};
|
|
539
|
+
return { draft, start, move, end };
|
|
540
|
+
}
|
|
541
|
+
var pointOf = (e) => ({ x: e.clientX, y: e.clientY });
|
|
542
|
+
function usePointerDrag({ onStart, onMove, onEnd }) {
|
|
543
|
+
const dragging = useRef(false);
|
|
544
|
+
const onPointerDown = useCallback(
|
|
545
|
+
(e) => {
|
|
546
|
+
dragging.current = true;
|
|
547
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
548
|
+
onStart?.(pointOf(e));
|
|
549
|
+
},
|
|
550
|
+
[onStart]
|
|
551
|
+
);
|
|
552
|
+
const onPointerMove = useCallback(
|
|
553
|
+
(e) => {
|
|
554
|
+
if (dragging.current) onMove?.(pointOf(e));
|
|
555
|
+
},
|
|
556
|
+
[onMove]
|
|
557
|
+
);
|
|
558
|
+
const onPointerUp = useCallback(
|
|
559
|
+
(e) => {
|
|
560
|
+
if (!dragging.current) return;
|
|
561
|
+
dragging.current = false;
|
|
562
|
+
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
563
|
+
onEnd?.(pointOf(e));
|
|
564
|
+
},
|
|
565
|
+
[onEnd]
|
|
566
|
+
);
|
|
567
|
+
return { onPointerDown, onPointerMove, onPointerUp };
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// src/hooks/useSpawnGesture.ts
|
|
571
|
+
var TAP_THRESHOLD = 8;
|
|
572
|
+
function useSpawnGesture(onDrop) {
|
|
573
|
+
const [ghost, setGhost] = useState(null);
|
|
574
|
+
const origin = useRef(null);
|
|
575
|
+
const handlers = usePointerDrag({
|
|
576
|
+
onStart: (p) => {
|
|
577
|
+
origin.current = p;
|
|
578
|
+
setGhost(p);
|
|
579
|
+
},
|
|
580
|
+
onMove: setGhost,
|
|
581
|
+
onEnd: (p) => {
|
|
582
|
+
setGhost(null);
|
|
583
|
+
const from = origin.current;
|
|
584
|
+
const tapped = !from || Math.hypot(p.x - from.x, p.y - from.y) < TAP_THRESHOLD;
|
|
585
|
+
const drop = tapped ? viewportSpot() : p;
|
|
586
|
+
onDrop(drop.x, drop.y);
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
return { ghost, handlers };
|
|
590
|
+
}
|
|
591
|
+
function viewportSpot() {
|
|
592
|
+
return { x: window.innerWidth / 2 - NOTE_WIDTH / 2, y: window.innerHeight / 3 };
|
|
593
|
+
}
|
|
594
|
+
function useAutoGrow(text) {
|
|
595
|
+
const ref = useRef(null);
|
|
596
|
+
useEffect(() => {
|
|
597
|
+
const el = ref.current;
|
|
598
|
+
if (!el) return;
|
|
599
|
+
el.style.height = "auto";
|
|
600
|
+
el.style.height = `${el.scrollHeight}px`;
|
|
601
|
+
}, [text]);
|
|
602
|
+
return ref;
|
|
603
|
+
}
|
|
604
|
+
function useElementHeight(ref, onHeight, onGone) {
|
|
605
|
+
useEffect(() => {
|
|
606
|
+
const el = ref.current;
|
|
607
|
+
if (!el) return;
|
|
608
|
+
const report = () => onHeight(el.offsetHeight);
|
|
609
|
+
report();
|
|
610
|
+
const observer = new ResizeObserver(report);
|
|
611
|
+
observer.observe(el);
|
|
612
|
+
return () => {
|
|
613
|
+
observer.disconnect();
|
|
614
|
+
onGone();
|
|
615
|
+
};
|
|
616
|
+
}, [ref]);
|
|
617
|
+
}
|
|
618
|
+
var IconButton = forwardRef(function IconButton2({ children, variant = "ghost", size = "md", tone = "accent", active = false, badge, className, ...rest }, ref) {
|
|
619
|
+
const classes = [
|
|
620
|
+
"fb-btn",
|
|
621
|
+
`fb-btn--${variant}`,
|
|
622
|
+
`fb-btn--${size}`,
|
|
623
|
+
`fb-btn--${tone}`,
|
|
624
|
+
active ? "is-active" : "",
|
|
625
|
+
className ?? ""
|
|
626
|
+
].filter(Boolean).join(" ");
|
|
627
|
+
const button = /* @__PURE__ */ jsx("button", { ref, className: classes, ...rest, children });
|
|
628
|
+
if (badge == null) return button;
|
|
629
|
+
return /* @__PURE__ */ jsxs("span", { className: "fb-btn-wrap", children: [
|
|
630
|
+
button,
|
|
631
|
+
/* @__PURE__ */ jsx("span", { className: "fb-btn__badge", children: badge })
|
|
632
|
+
] });
|
|
633
|
+
});
|
|
634
|
+
function Icon({ size = 16, className, children, ...rest }) {
|
|
635
|
+
return /* @__PURE__ */ jsx(
|
|
636
|
+
"svg",
|
|
637
|
+
{
|
|
638
|
+
width: size,
|
|
639
|
+
height: size,
|
|
640
|
+
viewBox: "0 0 24 24",
|
|
641
|
+
fill: "none",
|
|
642
|
+
stroke: "currentColor",
|
|
643
|
+
strokeWidth: "2",
|
|
644
|
+
strokeLinecap: "round",
|
|
645
|
+
strokeLinejoin: "round",
|
|
646
|
+
className,
|
|
647
|
+
"aria-hidden": true,
|
|
648
|
+
...rest,
|
|
649
|
+
children
|
|
650
|
+
}
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
function NoteIcon() {
|
|
654
|
+
return /* @__PURE__ */ jsxs(Icon, { size: 20, children: [
|
|
655
|
+
/* @__PURE__ */ jsx("path", { d: "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" }),
|
|
656
|
+
/* @__PURE__ */ jsx("line", { x1: "12", y1: "8", x2: "12", y2: "14" }),
|
|
657
|
+
/* @__PURE__ */ jsx("line", { x1: "9", y1: "11", x2: "15", y2: "11" })
|
|
658
|
+
] });
|
|
659
|
+
}
|
|
660
|
+
function SaveIcon() {
|
|
661
|
+
return /* @__PURE__ */ jsxs(Icon, { size: 20, children: [
|
|
662
|
+
/* @__PURE__ */ jsx("path", { d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }),
|
|
663
|
+
/* @__PURE__ */ jsx("polyline", { points: "7 10 12 15 17 10" }),
|
|
664
|
+
/* @__PURE__ */ jsx("line", { x1: "12", y1: "15", x2: "12", y2: "3" })
|
|
665
|
+
] });
|
|
666
|
+
}
|
|
667
|
+
function LoadIcon() {
|
|
668
|
+
return /* @__PURE__ */ jsxs(Icon, { size: 20, children: [
|
|
669
|
+
/* @__PURE__ */ jsx("path", { d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }),
|
|
670
|
+
/* @__PURE__ */ jsx("polyline", { points: "17 8 12 3 7 8" }),
|
|
671
|
+
/* @__PURE__ */ jsx("line", { x1: "12", y1: "3", x2: "12", y2: "15" })
|
|
672
|
+
] });
|
|
673
|
+
}
|
|
674
|
+
function SendIcon() {
|
|
675
|
+
return /* @__PURE__ */ jsxs(Icon, { size: 20, children: [
|
|
676
|
+
/* @__PURE__ */ jsx("line", { x1: "22", y1: "2", x2: "11", y2: "13" }),
|
|
677
|
+
/* @__PURE__ */ jsx("polygon", { points: "22 2 15 22 11 13 2 9 22 2" })
|
|
678
|
+
] });
|
|
679
|
+
}
|
|
680
|
+
function SentIcon() {
|
|
681
|
+
return /* @__PURE__ */ jsxs(Icon, { size: 20, children: [
|
|
682
|
+
/* @__PURE__ */ jsx("path", { d: "M22 11.08V12a10 10 0 1 1-5.93-9.14" }),
|
|
683
|
+
/* @__PURE__ */ jsx("polyline", { points: "22 4 12 14.01 9 11.01" })
|
|
684
|
+
] });
|
|
685
|
+
}
|
|
686
|
+
function HelpIcon() {
|
|
687
|
+
return /* @__PURE__ */ jsxs(Icon, { size: 20, children: [
|
|
688
|
+
/* @__PURE__ */ jsx("circle", { cx: "12", cy: "12", r: "10" }),
|
|
689
|
+
/* @__PURE__ */ jsx("path", { d: "M9.1 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" }),
|
|
690
|
+
/* @__PURE__ */ jsx("line", { x1: "12", y1: "17", x2: "12.01", y2: "17" })
|
|
691
|
+
] });
|
|
692
|
+
}
|
|
693
|
+
function ReviewIcon() {
|
|
694
|
+
return /* @__PURE__ */ jsxs(Icon, { size: 20, children: [
|
|
695
|
+
/* @__PURE__ */ jsx("path", { d: "M9 4h6a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1z" }),
|
|
696
|
+
/* @__PURE__ */ jsx("path", { d: "M8 5H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2" }),
|
|
697
|
+
/* @__PURE__ */ jsx("polyline", { points: "9 14 11 16 15 12" })
|
|
698
|
+
] });
|
|
699
|
+
}
|
|
700
|
+
function CloseIcon() {
|
|
701
|
+
return /* @__PURE__ */ jsxs(Icon, { size: 14, children: [
|
|
702
|
+
/* @__PURE__ */ jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" }),
|
|
703
|
+
/* @__PURE__ */ jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" })
|
|
704
|
+
] });
|
|
705
|
+
}
|
|
706
|
+
function CheckIcon() {
|
|
707
|
+
return /* @__PURE__ */ jsx(Icon, { size: 14, children: /* @__PURE__ */ jsx("polyline", { points: "20 6 9 17 4 12" }) });
|
|
708
|
+
}
|
|
709
|
+
function TrashIcon() {
|
|
710
|
+
return /* @__PURE__ */ jsxs(Icon, { size: 16, children: [
|
|
711
|
+
/* @__PURE__ */ jsx("polyline", { points: "3 6 5 6 21 6" }),
|
|
712
|
+
/* @__PURE__ */ jsx("path", { d: "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" }),
|
|
713
|
+
/* @__PURE__ */ jsx("line", { x1: "10", y1: "11", x2: "10", y2: "17" }),
|
|
714
|
+
/* @__PURE__ */ jsx("line", { x1: "14", y1: "11", x2: "14", y2: "17" })
|
|
715
|
+
] });
|
|
716
|
+
}
|
|
717
|
+
function GripIcon() {
|
|
718
|
+
return /* @__PURE__ */ jsxs(Icon, { size: 14, fill: "currentColor", stroke: "none", children: [
|
|
719
|
+
/* @__PURE__ */ jsx("circle", { cx: "9", cy: "6", r: "1.4" }),
|
|
720
|
+
/* @__PURE__ */ jsx("circle", { cx: "15", cy: "6", r: "1.4" }),
|
|
721
|
+
/* @__PURE__ */ jsx("circle", { cx: "9", cy: "12", r: "1.4" }),
|
|
722
|
+
/* @__PURE__ */ jsx("circle", { cx: "15", cy: "12", r: "1.4" }),
|
|
723
|
+
/* @__PURE__ */ jsx("circle", { cx: "9", cy: "18", r: "1.4" }),
|
|
724
|
+
/* @__PURE__ */ jsx("circle", { cx: "15", cy: "18", r: "1.4" })
|
|
725
|
+
] });
|
|
726
|
+
}
|
|
727
|
+
function FirstIcon() {
|
|
728
|
+
return /* @__PURE__ */ jsxs(Icon, { size: 18, children: [
|
|
729
|
+
/* @__PURE__ */ jsx("polygon", { points: "19 20 9 12 19 4", fill: "currentColor", stroke: "none" }),
|
|
730
|
+
/* @__PURE__ */ jsx("line", { x1: "5", y1: "19", x2: "5", y2: "5" })
|
|
731
|
+
] });
|
|
732
|
+
}
|
|
733
|
+
function PrevIcon() {
|
|
734
|
+
return /* @__PURE__ */ jsx(Icon, { size: 18, children: /* @__PURE__ */ jsx("polyline", { points: "15 18 9 12 15 6" }) });
|
|
735
|
+
}
|
|
736
|
+
function NextIcon() {
|
|
737
|
+
return /* @__PURE__ */ jsx(Icon, { size: 18, children: /* @__PURE__ */ jsx("polyline", { points: "9 18 15 12 9 6" }) });
|
|
738
|
+
}
|
|
739
|
+
function LastIcon() {
|
|
740
|
+
return /* @__PURE__ */ jsxs(Icon, { size: 18, children: [
|
|
741
|
+
/* @__PURE__ */ jsx("polygon", { points: "5 4 15 12 5 20", fill: "currentColor", stroke: "none" }),
|
|
742
|
+
/* @__PURE__ */ jsx("line", { x1: "19", y1: "5", x2: "19", y2: "19" })
|
|
743
|
+
] });
|
|
744
|
+
}
|
|
745
|
+
function EyeIcon() {
|
|
746
|
+
return /* @__PURE__ */ jsxs(Icon, { size: 18, children: [
|
|
747
|
+
/* @__PURE__ */ jsx("path", { d: "M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" }),
|
|
748
|
+
/* @__PURE__ */ jsx("circle", { cx: "12", cy: "12", r: "3" })
|
|
749
|
+
] });
|
|
750
|
+
}
|
|
751
|
+
function StickyNote({ note, originX: originX2, autoFocus, current, onMove, onEdit, onToggleResolved, onRemove }) {
|
|
752
|
+
const { reportHeight, clearHeight } = useFeedback();
|
|
753
|
+
const rootRef = useRef(null);
|
|
754
|
+
const bodyRef = useAutoGrow(note.text);
|
|
755
|
+
useElementHeight(rootRef, (h) => reportHeight(note.id, h), () => clearHeight(note.id));
|
|
756
|
+
const origin = useRef({ px: 0, py: 0, nx: 0, ny: 0 });
|
|
757
|
+
const grip = usePointerDrag({
|
|
758
|
+
onStart: (p) => origin.current = { px: p.x, py: p.y, nx: note.x, ny: note.y },
|
|
759
|
+
onMove: (p) => {
|
|
760
|
+
const o = origin.current;
|
|
761
|
+
onMove(o.nx + (p.x - o.px), Math.max(0, o.ny + (p.y - o.py)));
|
|
762
|
+
}
|
|
763
|
+
});
|
|
764
|
+
const className = [
|
|
765
|
+
"fb-note",
|
|
766
|
+
current ? "fb-note--current" : "",
|
|
767
|
+
note.resolved ? "fb-note--resolved" : ""
|
|
768
|
+
].filter(Boolean).join(" ");
|
|
769
|
+
return /* @__PURE__ */ jsxs("div", { ref: rootRef, "data-note-id": note.id, className, style: { left: originX2 + note.x, top: note.y, width: NOTE_WIDTH }, children: [
|
|
770
|
+
/* @__PURE__ */ jsxs("header", { ...grip, className: "fb-note__header", children: [
|
|
771
|
+
/* @__PURE__ */ jsx(GripIcon, {}),
|
|
772
|
+
/* @__PURE__ */ jsxs("div", { className: "fb-note__actions", children: [
|
|
773
|
+
/* @__PURE__ */ jsx(
|
|
774
|
+
IconButton,
|
|
775
|
+
{
|
|
776
|
+
variant: "ghost",
|
|
777
|
+
size: "sm",
|
|
778
|
+
tone: "success",
|
|
779
|
+
active: note.resolved,
|
|
780
|
+
onClick: onToggleResolved,
|
|
781
|
+
onPointerDown: stop,
|
|
782
|
+
title: note.resolved ? "Resolved \u2014 click to reopen" : "Mark resolved",
|
|
783
|
+
"aria-label": note.resolved ? "Reopen note" : "Mark resolved",
|
|
784
|
+
children: /* @__PURE__ */ jsx(CheckIcon, {})
|
|
785
|
+
}
|
|
786
|
+
),
|
|
787
|
+
/* @__PURE__ */ jsx(
|
|
788
|
+
IconButton,
|
|
789
|
+
{
|
|
790
|
+
variant: "ghost",
|
|
791
|
+
size: "sm",
|
|
792
|
+
tone: "danger",
|
|
793
|
+
onClick: onRemove,
|
|
794
|
+
onPointerDown: stop,
|
|
795
|
+
title: "Delete note",
|
|
796
|
+
"aria-label": "Delete note",
|
|
797
|
+
children: /* @__PURE__ */ jsx(CloseIcon, {})
|
|
798
|
+
}
|
|
799
|
+
)
|
|
800
|
+
] })
|
|
801
|
+
] }),
|
|
802
|
+
/* @__PURE__ */ jsx(
|
|
803
|
+
"textarea",
|
|
804
|
+
{
|
|
805
|
+
ref: bodyRef,
|
|
806
|
+
value: note.text,
|
|
807
|
+
autoFocus,
|
|
808
|
+
onChange: (e) => onEdit(e.target.value),
|
|
809
|
+
placeholder: "Write feedback\u2026",
|
|
810
|
+
className: "fb-note__body",
|
|
811
|
+
style: { minHeight: NOTE_MIN_HEIGHT - 28 }
|
|
812
|
+
}
|
|
813
|
+
)
|
|
814
|
+
] });
|
|
815
|
+
}
|
|
816
|
+
var stop = (e) => e.stopPropagation();
|
|
817
|
+
function NoteKnobs({ note, height, originX: originX2, onStart, onMove, onEnd }) {
|
|
818
|
+
return /* @__PURE__ */ jsx(Fragment, { children: PORTS.map((port) => {
|
|
819
|
+
const k = knobPoint(note, port, height);
|
|
820
|
+
return /* @__PURE__ */ jsx(
|
|
821
|
+
Knob,
|
|
822
|
+
{
|
|
823
|
+
x: originX2 + k.x,
|
|
824
|
+
y: k.y,
|
|
825
|
+
onStart: () => onStart(note.id, port),
|
|
826
|
+
onMove,
|
|
827
|
+
onEnd
|
|
828
|
+
},
|
|
829
|
+
port
|
|
830
|
+
);
|
|
831
|
+
}) });
|
|
832
|
+
}
|
|
833
|
+
function Knob({ x, y, onStart, onMove, onEnd }) {
|
|
834
|
+
const drag = usePointerDrag({
|
|
835
|
+
onStart,
|
|
836
|
+
onMove: (p) => onMove(p.x, p.y),
|
|
837
|
+
onEnd: (p) => onEnd(p.x, p.y)
|
|
838
|
+
});
|
|
839
|
+
return /* @__PURE__ */ jsx(
|
|
840
|
+
"span",
|
|
841
|
+
{
|
|
842
|
+
...drag,
|
|
843
|
+
title: "Drag to draw an arrow",
|
|
844
|
+
"aria-label": "Draw an arrow",
|
|
845
|
+
className: "fb-knob",
|
|
846
|
+
style: { left: x, top: y }
|
|
847
|
+
}
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
function withOrigin(p, originX2) {
|
|
851
|
+
return { x: originX2 + p.x, y: p.y };
|
|
852
|
+
}
|
|
853
|
+
function svgFrame(geo) {
|
|
854
|
+
return { left: geo.origin.x, top: geo.origin.y, width: geo.width, height: geo.height };
|
|
855
|
+
}
|
|
856
|
+
function ArrowBody({ geo, color, width }) {
|
|
857
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
858
|
+
/* @__PURE__ */ jsx(
|
|
859
|
+
"line",
|
|
860
|
+
{
|
|
861
|
+
x1: geo.start.x,
|
|
862
|
+
y1: geo.start.y,
|
|
863
|
+
x2: geo.base.x,
|
|
864
|
+
y2: geo.base.y,
|
|
865
|
+
stroke: color,
|
|
866
|
+
strokeWidth: width,
|
|
867
|
+
strokeLinecap: "round"
|
|
868
|
+
}
|
|
869
|
+
),
|
|
870
|
+
/* @__PURE__ */ jsx(
|
|
871
|
+
"polygon",
|
|
872
|
+
{
|
|
873
|
+
points: `${geo.head.x},${geo.head.y} ${geo.wings[0].x},${geo.wings[0].y} ${geo.wings[1].x},${geo.wings[1].y}`,
|
|
874
|
+
fill: color
|
|
875
|
+
}
|
|
876
|
+
)
|
|
877
|
+
] });
|
|
878
|
+
}
|
|
879
|
+
function Arrow({ start, head, color, width }) {
|
|
880
|
+
const geo = arrowGeometry(start, head);
|
|
881
|
+
return /* @__PURE__ */ jsx("svg", { className: "fb-arrow", style: svgFrame(geo), children: /* @__PURE__ */ jsx(ArrowBody, { geo, color, width }) });
|
|
882
|
+
}
|
|
883
|
+
var ACCENT = "var(--fb-accent)";
|
|
884
|
+
var ACCENT_HOVER = "var(--fb-accent-hover)";
|
|
885
|
+
function Edge({ start, head, originX: originX2, selected, onSelect, onMoveHead, onReaim }) {
|
|
886
|
+
const geo = arrowGeometry(withOrigin(start, originX2), withOrigin(head, originX2));
|
|
887
|
+
const grab = useRef({ hx: 0, hy: 0, px: 0, py: 0 });
|
|
888
|
+
const drag = usePointerDrag({
|
|
889
|
+
onStart: (p) => grab.current = { hx: head.x, hy: head.y, px: p.x, py: p.y },
|
|
890
|
+
onMove: (p) => onMoveHead(grab.current.hx + (p.x - grab.current.px), grab.current.hy + (p.y - grab.current.py)),
|
|
891
|
+
onEnd: (p) => onReaim(p.x, p.y)
|
|
892
|
+
});
|
|
893
|
+
const color = selected ? ACCENT_HOVER : ACCENT;
|
|
894
|
+
const select = (e) => {
|
|
895
|
+
e.stopPropagation();
|
|
896
|
+
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
|
|
897
|
+
onSelect();
|
|
898
|
+
};
|
|
899
|
+
return /* @__PURE__ */ jsxs("svg", { className: "fb-arrow", style: svgFrame(geo), children: [
|
|
900
|
+
/* @__PURE__ */ jsx(
|
|
901
|
+
"line",
|
|
902
|
+
{
|
|
903
|
+
x1: geo.start.x,
|
|
904
|
+
y1: geo.start.y,
|
|
905
|
+
x2: geo.head.x,
|
|
906
|
+
y2: geo.head.y,
|
|
907
|
+
stroke: "transparent",
|
|
908
|
+
strokeWidth: 14,
|
|
909
|
+
strokeLinecap: "round",
|
|
910
|
+
className: "fb-edge__hit",
|
|
911
|
+
onPointerDown: select
|
|
912
|
+
}
|
|
913
|
+
),
|
|
914
|
+
/* @__PURE__ */ jsx(ArrowBody, { geo, color, width: selected ? 3 : 2 }),
|
|
915
|
+
/* @__PURE__ */ jsx(
|
|
916
|
+
"circle",
|
|
917
|
+
{
|
|
918
|
+
cx: geo.head.x,
|
|
919
|
+
cy: geo.head.y,
|
|
920
|
+
r: selected ? 7 : 5,
|
|
921
|
+
fill: color,
|
|
922
|
+
stroke: "var(--fb-surface)",
|
|
923
|
+
strokeWidth: selected ? 2 : 0,
|
|
924
|
+
className: "fb-edge__head",
|
|
925
|
+
onPointerDown: (e) => {
|
|
926
|
+
select(e);
|
|
927
|
+
drag.onPointerDown(e);
|
|
928
|
+
},
|
|
929
|
+
onPointerMove: drag.onPointerMove,
|
|
930
|
+
onPointerUp: drag.onPointerUp
|
|
931
|
+
}
|
|
932
|
+
)
|
|
933
|
+
] });
|
|
934
|
+
}
|
|
935
|
+
function DraftEdge({ start, head, originX: originX2 }) {
|
|
936
|
+
return /* @__PURE__ */ jsx(Arrow, { start: withOrigin(start, originX2), head: withOrigin(head, originX2), color: "var(--fb-accent)", width: 2 });
|
|
937
|
+
}
|
|
938
|
+
function Overlay({ originX: originX2, focusId, reviewCurrentId, reviewing, selectedEdge, onSelectEdge, draft }) {
|
|
939
|
+
const { doc, region, section, nav, heightOf, rootClassName, rootStyle } = useFeedback();
|
|
940
|
+
const inScope = (o) => o.page === nav.page && (o.section ?? null) === (section ?? null);
|
|
941
|
+
const noteById = (id) => doc.notes.find((n) => n.id === id);
|
|
942
|
+
const pageNotes = doc.notes.filter(inScope);
|
|
943
|
+
const pageEdges = doc.edges.filter(inScope);
|
|
944
|
+
const draftNote = draft.draft ? noteById(draft.draft.noteId) : null;
|
|
945
|
+
return createPortal(
|
|
946
|
+
/* @__PURE__ */ jsxs("div", { "data-feedback": true, className: `${rootClassName} fb-overlay`, style: rootStyle, children: [
|
|
947
|
+
pageEdges.map((edge) => {
|
|
948
|
+
const note = noteById(edge.noteId);
|
|
949
|
+
if (!note) return null;
|
|
950
|
+
return /* @__PURE__ */ jsx(
|
|
951
|
+
Edge,
|
|
952
|
+
{
|
|
953
|
+
start: knobPoint(note, edge.port, heightOf(note.id)),
|
|
954
|
+
head: { x: edge.x, y: edge.y },
|
|
955
|
+
originX: originX2,
|
|
956
|
+
selected: selectedEdge === edge.id,
|
|
957
|
+
onSelect: () => onSelectEdge(edge.id),
|
|
958
|
+
onMoveHead: (x, y) => doc.updateEdge(edge.id, { x, y }),
|
|
959
|
+
onReaim: (x, y) => doc.updateEdge(edge.id, { target: describeTarget(x, y) })
|
|
960
|
+
},
|
|
961
|
+
edge.id
|
|
962
|
+
);
|
|
963
|
+
}),
|
|
964
|
+
draft.draft && draftNote && /* @__PURE__ */ jsx(DraftEdge, { start: knobPoint(draftNote, draft.draft.port, heightOf(draftNote.id)), head: draft.draft.head, originX: originX2 }),
|
|
965
|
+
pageNotes.map((note) => /* @__PURE__ */ jsx(
|
|
966
|
+
StickyNote,
|
|
967
|
+
{
|
|
968
|
+
note,
|
|
969
|
+
originX: originX2,
|
|
970
|
+
autoFocus: note.id === focusId,
|
|
971
|
+
current: reviewing && note.id === reviewCurrentId,
|
|
972
|
+
onMove: (x, y) => doc.updateNote(note.id, { x, y }),
|
|
973
|
+
onEdit: (text) => doc.updateNote(note.id, { text }),
|
|
974
|
+
onToggleResolved: () => doc.updateNote(note.id, { resolved: !note.resolved }),
|
|
975
|
+
onRemove: () => doc.removeNote(note.id)
|
|
976
|
+
},
|
|
977
|
+
note.id
|
|
978
|
+
)),
|
|
979
|
+
pageNotes.map((note) => /* @__PURE__ */ jsx(
|
|
980
|
+
NoteKnobs,
|
|
981
|
+
{
|
|
982
|
+
note,
|
|
983
|
+
height: heightOf(note.id),
|
|
984
|
+
originX: originX2,
|
|
985
|
+
onStart: draft.start,
|
|
986
|
+
onMove: draft.move,
|
|
987
|
+
onEnd: draft.end
|
|
988
|
+
},
|
|
989
|
+
`knobs-${note.id}`
|
|
990
|
+
))
|
|
991
|
+
] }),
|
|
992
|
+
region.element ?? document.body
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
function GhostNote({ x, y }) {
|
|
996
|
+
return /* @__PURE__ */ jsx("div", { "aria-hidden": true, className: "fb-ghost", style: { left: x, top: y, width: NOTE_WIDTH, height: NOTE_MIN_HEIGHT } });
|
|
997
|
+
}
|
|
998
|
+
var SUBMIT_TITLES = {
|
|
999
|
+
idle: "Send all feedback notes",
|
|
1000
|
+
sending: "Sending feedback\u2026",
|
|
1001
|
+
sent: "Feedback sent",
|
|
1002
|
+
error: "Sending failed \u2014 try again"
|
|
1003
|
+
};
|
|
1004
|
+
function FeedbackDock({ launcher, onSave, onLoad, onSubmit, submitStatus, onToggleReview, reviewing, onToggleHelp, helpOpen, helpRef, count }) {
|
|
1005
|
+
return /* @__PURE__ */ jsxs("div", { className: "fb-dock", children: [
|
|
1006
|
+
/* @__PURE__ */ jsx(
|
|
1007
|
+
IconButton,
|
|
1008
|
+
{
|
|
1009
|
+
ref: helpRef,
|
|
1010
|
+
variant: "solid",
|
|
1011
|
+
size: "lg",
|
|
1012
|
+
active: helpOpen,
|
|
1013
|
+
"aria-pressed": helpOpen,
|
|
1014
|
+
onClick: onToggleHelp,
|
|
1015
|
+
title: "How feedback works",
|
|
1016
|
+
children: /* @__PURE__ */ jsx(HelpIcon, {})
|
|
1017
|
+
}
|
|
1018
|
+
),
|
|
1019
|
+
/* @__PURE__ */ jsx(
|
|
1020
|
+
IconButton,
|
|
1021
|
+
{
|
|
1022
|
+
...launcher,
|
|
1023
|
+
variant: "solid",
|
|
1024
|
+
size: "lg",
|
|
1025
|
+
badge: count || void 0,
|
|
1026
|
+
title: "Drag onto the page to leave a feedback note",
|
|
1027
|
+
className: "fb-dock__launcher",
|
|
1028
|
+
children: /* @__PURE__ */ jsx(NoteIcon, {})
|
|
1029
|
+
}
|
|
1030
|
+
),
|
|
1031
|
+
onSubmit && /* @__PURE__ */ jsx(
|
|
1032
|
+
IconButton,
|
|
1033
|
+
{
|
|
1034
|
+
variant: "solid",
|
|
1035
|
+
size: "lg",
|
|
1036
|
+
tone: submitStatus === "error" ? "danger" : "success",
|
|
1037
|
+
active: submitStatus === "sent" || submitStatus === "error",
|
|
1038
|
+
onClick: onSubmit,
|
|
1039
|
+
disabled: count === 0 || submitStatus === "sending",
|
|
1040
|
+
className: submitStatus === "sending" ? "fb-dock__send is-sending" : "fb-dock__send",
|
|
1041
|
+
title: SUBMIT_TITLES[submitStatus],
|
|
1042
|
+
children: submitStatus === "sent" ? /* @__PURE__ */ jsx(SentIcon, {}) : /* @__PURE__ */ jsx(SendIcon, {})
|
|
1043
|
+
}
|
|
1044
|
+
),
|
|
1045
|
+
/* @__PURE__ */ jsx(IconButton, { variant: "solid", size: "lg", onClick: onSave, disabled: count === 0, title: "Save all feedback notes to a file", children: /* @__PURE__ */ jsx(SaveIcon, {}) }),
|
|
1046
|
+
/* @__PURE__ */ jsx(IconButton, { variant: "solid", size: "lg", onClick: onLoad, title: "Load feedback notes from a file", children: /* @__PURE__ */ jsx(LoadIcon, {}) }),
|
|
1047
|
+
/* @__PURE__ */ jsx(
|
|
1048
|
+
IconButton,
|
|
1049
|
+
{
|
|
1050
|
+
variant: "solid",
|
|
1051
|
+
size: "lg",
|
|
1052
|
+
active: reviewing,
|
|
1053
|
+
"aria-pressed": reviewing,
|
|
1054
|
+
onClick: onToggleReview,
|
|
1055
|
+
disabled: count === 0,
|
|
1056
|
+
title: reviewing ? "Exit review mode" : "Review the loaded notes",
|
|
1057
|
+
children: /* @__PURE__ */ jsx(ReviewIcon, {})
|
|
1058
|
+
}
|
|
1059
|
+
)
|
|
1060
|
+
] });
|
|
1061
|
+
}
|
|
1062
|
+
function Toolbar({ children, className, ...rest }) {
|
|
1063
|
+
return /* @__PURE__ */ jsx("div", { className: ["fb-toolbar", className].filter(Boolean).join(" "), ...rest, children });
|
|
1064
|
+
}
|
|
1065
|
+
function ToolbarDivider() {
|
|
1066
|
+
return /* @__PURE__ */ jsx("span", { className: "fb-toolbar__divider" });
|
|
1067
|
+
}
|
|
1068
|
+
function ReviewBar({
|
|
1069
|
+
position,
|
|
1070
|
+
total,
|
|
1071
|
+
hasCurrent,
|
|
1072
|
+
currentResolved,
|
|
1073
|
+
includeResolved,
|
|
1074
|
+
onFirst,
|
|
1075
|
+
onPrev,
|
|
1076
|
+
onNext,
|
|
1077
|
+
onLast,
|
|
1078
|
+
onResolveCurrent,
|
|
1079
|
+
onDeleteCurrent,
|
|
1080
|
+
onToggleIncludeResolved,
|
|
1081
|
+
onExit
|
|
1082
|
+
}) {
|
|
1083
|
+
const atStart = position <= 1;
|
|
1084
|
+
const atEnd = position >= total;
|
|
1085
|
+
const empty = total === 0;
|
|
1086
|
+
return /* @__PURE__ */ jsxs(Toolbar, { className: "fb-reviewbar", role: "toolbar", "aria-label": "Review notes", children: [
|
|
1087
|
+
/* @__PURE__ */ jsx(IconButton, { onClick: onFirst, disabled: atStart, title: "First note", children: /* @__PURE__ */ jsx(FirstIcon, {}) }),
|
|
1088
|
+
/* @__PURE__ */ jsx(IconButton, { onClick: onPrev, disabled: atStart, title: "Previous note", children: /* @__PURE__ */ jsx(PrevIcon, {}) }),
|
|
1089
|
+
/* @__PURE__ */ jsx("span", { className: "fb-reviewbar__count", children: empty ? "no notes" : `${position} / ${total}` }),
|
|
1090
|
+
/* @__PURE__ */ jsx(IconButton, { onClick: onNext, disabled: atEnd, title: "Next note", children: /* @__PURE__ */ jsx(NextIcon, {}) }),
|
|
1091
|
+
/* @__PURE__ */ jsx(IconButton, { onClick: onLast, disabled: atEnd, title: "Last note", children: /* @__PURE__ */ jsx(LastIcon, {}) }),
|
|
1092
|
+
/* @__PURE__ */ jsx(ToolbarDivider, {}),
|
|
1093
|
+
/* @__PURE__ */ jsx(
|
|
1094
|
+
IconButton,
|
|
1095
|
+
{
|
|
1096
|
+
tone: "success",
|
|
1097
|
+
active: currentResolved,
|
|
1098
|
+
"aria-pressed": currentResolved,
|
|
1099
|
+
onClick: onResolveCurrent,
|
|
1100
|
+
disabled: !hasCurrent,
|
|
1101
|
+
title: currentResolved ? "Reopen this note" : "Resolve this note",
|
|
1102
|
+
children: /* @__PURE__ */ jsx(CheckIcon, {})
|
|
1103
|
+
}
|
|
1104
|
+
),
|
|
1105
|
+
/* @__PURE__ */ jsx(IconButton, { tone: "danger", onClick: onDeleteCurrent, disabled: !hasCurrent, title: "Delete this note", children: /* @__PURE__ */ jsx(TrashIcon, {}) }),
|
|
1106
|
+
/* @__PURE__ */ jsx(ToolbarDivider, {}),
|
|
1107
|
+
/* @__PURE__ */ jsx(
|
|
1108
|
+
IconButton,
|
|
1109
|
+
{
|
|
1110
|
+
active: includeResolved,
|
|
1111
|
+
"aria-pressed": includeResolved,
|
|
1112
|
+
onClick: onToggleIncludeResolved,
|
|
1113
|
+
title: includeResolved ? "Including resolved notes" : "Skipping resolved notes",
|
|
1114
|
+
children: /* @__PURE__ */ jsx(EyeIcon, {})
|
|
1115
|
+
}
|
|
1116
|
+
),
|
|
1117
|
+
/* @__PURE__ */ jsx(IconButton, { onClick: onExit, title: "Exit review", children: /* @__PURE__ */ jsx(CloseIcon, {}) })
|
|
1118
|
+
] });
|
|
1119
|
+
}
|
|
1120
|
+
var GAP = 8;
|
|
1121
|
+
var MARGIN = 12;
|
|
1122
|
+
function useAnchoredPosition(anchor, panelRef) {
|
|
1123
|
+
const [pos, setPos] = useState(null);
|
|
1124
|
+
useLayoutEffect(() => {
|
|
1125
|
+
const place = () => {
|
|
1126
|
+
const panel = panelRef.current;
|
|
1127
|
+
if (!panel || !anchor) return;
|
|
1128
|
+
const a = anchor.getBoundingClientRect();
|
|
1129
|
+
const p = panel.getBoundingClientRect();
|
|
1130
|
+
setPos({
|
|
1131
|
+
left: clamp(a.right - p.width, MARGIN, window.innerWidth - p.width - MARGIN),
|
|
1132
|
+
top: clamp(a.top - p.height - GAP, MARGIN, window.innerHeight - p.height - MARGIN)
|
|
1133
|
+
});
|
|
1134
|
+
};
|
|
1135
|
+
place();
|
|
1136
|
+
window.addEventListener("resize", place);
|
|
1137
|
+
window.addEventListener("scroll", place, true);
|
|
1138
|
+
return () => {
|
|
1139
|
+
window.removeEventListener("resize", place);
|
|
1140
|
+
window.removeEventListener("scroll", place, true);
|
|
1141
|
+
};
|
|
1142
|
+
}, [anchor, panelRef]);
|
|
1143
|
+
return pos;
|
|
1144
|
+
}
|
|
1145
|
+
function clamp(value, lo, hi) {
|
|
1146
|
+
return Math.max(lo, Math.min(value, hi));
|
|
1147
|
+
}
|
|
1148
|
+
function Popover({ anchor, onClose, label, className, style, children }) {
|
|
1149
|
+
const ref = useRef(null);
|
|
1150
|
+
const pos = useAnchoredPosition(anchor, ref);
|
|
1151
|
+
useEffect(() => {
|
|
1152
|
+
const onDown = (e) => {
|
|
1153
|
+
const target = e.target;
|
|
1154
|
+
if (!ref.current?.contains(target) && !anchor?.contains(target)) onClose();
|
|
1155
|
+
};
|
|
1156
|
+
const onKey = (e) => e.key === "Escape" && onClose();
|
|
1157
|
+
document.addEventListener("pointerdown", onDown);
|
|
1158
|
+
document.addEventListener("keydown", onKey);
|
|
1159
|
+
return () => {
|
|
1160
|
+
document.removeEventListener("pointerdown", onDown);
|
|
1161
|
+
document.removeEventListener("keydown", onKey);
|
|
1162
|
+
};
|
|
1163
|
+
}, [anchor, onClose]);
|
|
1164
|
+
return createPortal(
|
|
1165
|
+
/* @__PURE__ */ jsx(
|
|
1166
|
+
"div",
|
|
1167
|
+
{
|
|
1168
|
+
ref,
|
|
1169
|
+
"data-feedback": true,
|
|
1170
|
+
role: "dialog",
|
|
1171
|
+
"aria-label": label,
|
|
1172
|
+
className: ["fb-popover", className].filter(Boolean).join(" "),
|
|
1173
|
+
style: {
|
|
1174
|
+
top: pos?.top ?? -9999,
|
|
1175
|
+
left: pos?.left ?? -9999,
|
|
1176
|
+
visibility: pos ? "visible" : "hidden",
|
|
1177
|
+
...style
|
|
1178
|
+
},
|
|
1179
|
+
children
|
|
1180
|
+
}
|
|
1181
|
+
),
|
|
1182
|
+
document.body
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
function HelpGuide({ anchor, onClose }) {
|
|
1186
|
+
const { submit, rootClassName, rootStyle } = useFeedback();
|
|
1187
|
+
return /* @__PURE__ */ jsxs(Popover, { anchor, onClose, label: "How feedback works", className: rootClassName, style: rootStyle, children: [
|
|
1188
|
+
/* @__PURE__ */ jsxs("header", { className: "fb-guide__header", children: [
|
|
1189
|
+
/* @__PURE__ */ jsx("h2", { className: "fb-guide__title", children: "Leaving feedback" }),
|
|
1190
|
+
/* @__PURE__ */ jsx("button", { onClick: onClose, "aria-label": "Close", className: "fb-guide__close", children: /* @__PURE__ */ jsx(CloseIcon, {}) })
|
|
1191
|
+
] }),
|
|
1192
|
+
/* @__PURE__ */ jsx("p", { className: "fb-guide__lead", children: "See something worth flagging? Mark it up right here on the page." }),
|
|
1193
|
+
/* @__PURE__ */ jsxs("ul", { className: "fb-guide__steps", children: [
|
|
1194
|
+
/* @__PURE__ */ jsxs(Step, { icon: /* @__PURE__ */ jsx(NoteIcon, {}), children: [
|
|
1195
|
+
/* @__PURE__ */ jsx("b", { children: "Drop a note." }),
|
|
1196
|
+
" Drag the",
|
|
1197
|
+
" ",
|
|
1198
|
+
/* @__PURE__ */ jsx(InlineIcon, { children: /* @__PURE__ */ jsx(NoteIcon, {}) }),
|
|
1199
|
+
" ",
|
|
1200
|
+
"button onto the page and let go where you want to comment."
|
|
1201
|
+
] }),
|
|
1202
|
+
/* @__PURE__ */ jsxs(Step, { icon: /* @__PURE__ */ jsx(GripIcon, {}), children: [
|
|
1203
|
+
/* @__PURE__ */ jsx("b", { children: "Move and describe." }),
|
|
1204
|
+
" Drag the note's top bar to slide it anywhere, and click its text to write down what you're flagging."
|
|
1205
|
+
] }),
|
|
1206
|
+
/* @__PURE__ */ jsxs(Step, { icon: /* @__PURE__ */ jsx(Knob2, {}), children: [
|
|
1207
|
+
/* @__PURE__ */ jsx("b", { children: "Point at it." }),
|
|
1208
|
+
" Drag from a knob on a note's edge to draw an arrow at exactly what you mean. Drag the arrowhead to fine-tune, or select it and press Delete."
|
|
1209
|
+
] }),
|
|
1210
|
+
/* @__PURE__ */ jsxs(Step, { icon: submit ? /* @__PURE__ */ jsx(SendIcon, {}) : /* @__PURE__ */ jsx(SaveIcon, {}), children: [
|
|
1211
|
+
/* @__PURE__ */ jsx("b", { children: "Send it in." }),
|
|
1212
|
+
" Notes save in your browser as you go. When you're done, hit",
|
|
1213
|
+
" ",
|
|
1214
|
+
submit ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1215
|
+
/* @__PURE__ */ jsx(InlineIcon, { children: /* @__PURE__ */ jsx(SendIcon, {}) }),
|
|
1216
|
+
" ",
|
|
1217
|
+
"to submit them."
|
|
1218
|
+
] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1219
|
+
/* @__PURE__ */ jsx(InlineIcon, { children: /* @__PURE__ */ jsx(SaveIcon, {}) }),
|
|
1220
|
+
" ",
|
|
1221
|
+
"to download them as a file and send it along."
|
|
1222
|
+
] })
|
|
1223
|
+
] })
|
|
1224
|
+
] })
|
|
1225
|
+
] });
|
|
1226
|
+
}
|
|
1227
|
+
function Step({ icon, children }) {
|
|
1228
|
+
return /* @__PURE__ */ jsxs("li", { className: "fb-guide__step", children: [
|
|
1229
|
+
/* @__PURE__ */ jsx("span", { className: "fb-guide__step-icon", children: icon }),
|
|
1230
|
+
/* @__PURE__ */ jsx("span", { className: "fb-guide__step-text", children })
|
|
1231
|
+
] });
|
|
1232
|
+
}
|
|
1233
|
+
function InlineIcon({ children }) {
|
|
1234
|
+
return /* @__PURE__ */ jsx("span", { className: "fb-guide__inline-icon", children });
|
|
1235
|
+
}
|
|
1236
|
+
function Knob2() {
|
|
1237
|
+
return /* @__PURE__ */ jsx("span", { className: "fb-guide__knob" });
|
|
1238
|
+
}
|
|
1239
|
+
function FeedbackLayer() {
|
|
1240
|
+
const { doc, nav, region, section, heightOf, fileName, submit, rootClassName, rootStyle } = useFeedback();
|
|
1241
|
+
const originX2 = useOriginX(region);
|
|
1242
|
+
const review = useReview(doc.notes);
|
|
1243
|
+
const [focusId, setFocusId] = useState(null);
|
|
1244
|
+
const [selectedEdge, setSelectedEdge] = useState(null);
|
|
1245
|
+
const [helpOpen, setHelpOpen] = useState(false);
|
|
1246
|
+
const helpRef = useRef(null);
|
|
1247
|
+
const noteById = useCallback((id) => doc.notes.find((n) => n.id === id), [doc.notes]);
|
|
1248
|
+
const draft = useEdgeDraft({
|
|
1249
|
+
region,
|
|
1250
|
+
noteById,
|
|
1251
|
+
heightOf,
|
|
1252
|
+
onCommit: (noteId, port, head, target) => doc.addEdge({ page: nav.page, section, noteId, port, x: head.x, y: head.y, target })
|
|
1253
|
+
});
|
|
1254
|
+
const spawn = useCallback(
|
|
1255
|
+
(clientX, clientY) => {
|
|
1256
|
+
const { x, y } = toContent(clientX, clientY, region);
|
|
1257
|
+
setFocusId(doc.addNote(nav.page, section, x, Math.max(0, y)));
|
|
1258
|
+
},
|
|
1259
|
+
[doc, nav.page, section, region]
|
|
1260
|
+
);
|
|
1261
|
+
const { ghost, handlers } = useSpawnGesture(spawn);
|
|
1262
|
+
const load = useCallback(async () => {
|
|
1263
|
+
const loaded = await importDoc();
|
|
1264
|
+
if (loaded) doc.replace(loaded);
|
|
1265
|
+
}, [doc]);
|
|
1266
|
+
const save = useCallback(() => void exportDoc({ notes: doc.notes, edges: doc.edges }, fileName), [doc, fileName]);
|
|
1267
|
+
const submission = useSubmission(submit);
|
|
1268
|
+
const send = useCallback(
|
|
1269
|
+
() => void submission.send({ notes: doc.notes, edges: doc.edges }),
|
|
1270
|
+
[submission.send, doc.notes, doc.edges]
|
|
1271
|
+
);
|
|
1272
|
+
useEffect(() => {
|
|
1273
|
+
const clear = () => setSelectedEdge(null);
|
|
1274
|
+
document.addEventListener("pointerdown", clear);
|
|
1275
|
+
return () => document.removeEventListener("pointerdown", clear);
|
|
1276
|
+
}, []);
|
|
1277
|
+
useEffect(() => {
|
|
1278
|
+
if (!selectedEdge) return;
|
|
1279
|
+
const onKey = (e) => {
|
|
1280
|
+
if ((e.key === "Delete" || e.key === "Backspace") && !isEditable(document.activeElement)) {
|
|
1281
|
+
e.preventDefault();
|
|
1282
|
+
doc.removeEdge(selectedEdge);
|
|
1283
|
+
setSelectedEdge(null);
|
|
1284
|
+
}
|
|
1285
|
+
};
|
|
1286
|
+
window.addEventListener("keydown", onKey);
|
|
1287
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
1288
|
+
}, [selectedEdge, doc]);
|
|
1289
|
+
const current = doc.notes.find((n) => n.id === review.currentId) ?? null;
|
|
1290
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1291
|
+
/* @__PURE__ */ jsx(
|
|
1292
|
+
Overlay,
|
|
1293
|
+
{
|
|
1294
|
+
originX: originX2,
|
|
1295
|
+
focusId,
|
|
1296
|
+
reviewCurrentId: review.currentId,
|
|
1297
|
+
reviewing: review.on,
|
|
1298
|
+
selectedEdge,
|
|
1299
|
+
onSelectEdge: setSelectedEdge,
|
|
1300
|
+
draft
|
|
1301
|
+
}
|
|
1302
|
+
),
|
|
1303
|
+
createPortal(
|
|
1304
|
+
/* @__PURE__ */ jsxs("div", { "data-feedback": true, className: rootClassName, style: rootStyle, children: [
|
|
1305
|
+
ghost && /* @__PURE__ */ jsx(GhostNote, { x: ghost.x, y: ghost.y }),
|
|
1306
|
+
review.on && /* @__PURE__ */ jsx(
|
|
1307
|
+
ReviewBar,
|
|
1308
|
+
{
|
|
1309
|
+
position: review.position,
|
|
1310
|
+
total: review.total,
|
|
1311
|
+
hasCurrent: current !== null,
|
|
1312
|
+
currentResolved: current?.resolved ?? false,
|
|
1313
|
+
includeResolved: review.includeResolved,
|
|
1314
|
+
onFirst: review.first,
|
|
1315
|
+
onPrev: review.prev,
|
|
1316
|
+
onNext: review.next,
|
|
1317
|
+
onLast: review.last,
|
|
1318
|
+
onResolveCurrent: () => current && doc.updateNote(current.id, { resolved: !current.resolved }),
|
|
1319
|
+
onDeleteCurrent: () => current && doc.removeNote(current.id),
|
|
1320
|
+
onToggleIncludeResolved: review.toggleIncludeResolved,
|
|
1321
|
+
onExit: review.toggle
|
|
1322
|
+
}
|
|
1323
|
+
),
|
|
1324
|
+
/* @__PURE__ */ jsx(
|
|
1325
|
+
FeedbackDock,
|
|
1326
|
+
{
|
|
1327
|
+
launcher: handlers,
|
|
1328
|
+
onSave: save,
|
|
1329
|
+
onLoad: () => void load(),
|
|
1330
|
+
onSubmit: submit ? send : null,
|
|
1331
|
+
submitStatus: submission.status,
|
|
1332
|
+
onToggleReview: review.toggle,
|
|
1333
|
+
reviewing: review.on,
|
|
1334
|
+
onToggleHelp: () => setHelpOpen((open) => !open),
|
|
1335
|
+
helpOpen,
|
|
1336
|
+
helpRef,
|
|
1337
|
+
count: doc.notes.length
|
|
1338
|
+
}
|
|
1339
|
+
),
|
|
1340
|
+
helpOpen && /* @__PURE__ */ jsx(HelpGuide, { anchor: helpRef.current, onClose: () => setHelpOpen(false) })
|
|
1341
|
+
] }),
|
|
1342
|
+
document.body
|
|
1343
|
+
)
|
|
1344
|
+
] });
|
|
1345
|
+
}
|
|
1346
|
+
function isEditable(el) {
|
|
1347
|
+
if (!el) return false;
|
|
1348
|
+
return el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable;
|
|
1349
|
+
}
|
|
1350
|
+
function FeedbackRegion({ children, anchorX, section = null, onReveal, className, style }) {
|
|
1351
|
+
const ref = useRef(null);
|
|
1352
|
+
const { bindRegion } = useFeedback();
|
|
1353
|
+
const revealRef = useRef(onReveal);
|
|
1354
|
+
revealRef.current = onReveal;
|
|
1355
|
+
const hasReveal = !!onReveal;
|
|
1356
|
+
useEffect(() => {
|
|
1357
|
+
bindRegion({
|
|
1358
|
+
element: ref.current,
|
|
1359
|
+
anchorX,
|
|
1360
|
+
section,
|
|
1361
|
+
reveal: hasReveal ? (s) => revealRef.current?.(s) : void 0
|
|
1362
|
+
});
|
|
1363
|
+
return () => bindRegion(null);
|
|
1364
|
+
}, [anchorX, section, hasReveal, bindRegion]);
|
|
1365
|
+
return /* @__PURE__ */ jsx("div", { ref, className, style: { ...style, position: "relative" }, children });
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
export { EMPTY_DOC, FeedbackLayer, FeedbackProvider, FeedbackRegion, endpointSubmitter, normalizeDoc };
|
|
1369
|
+
//# sourceMappingURL=index.js.map
|
|
1370
|
+
//# sourceMappingURL=index.js.map
|