d2d-feedbackkit 0.0.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 +13 -0
- package/package.json +83 -0
- package/src/agent.ts +161 -0
- package/src/babel.ts +285 -0
- package/src/client.ts +1 -0
- package/src/index.ts +358 -0
- package/src/linear.ts +130 -0
- package/src/shortcuts.ts +27 -0
- package/src/solid.tsx +2723 -0
- package/src/source.ts +248 -0
- package/src/types.ts +218 -0
- package/src/vite-plugin.ts +3 -0
- package/src/web-component.tsx +114 -0
- package/src/widget.tsx +1 -0
package/src/solid.tsx
ADDED
|
@@ -0,0 +1,2723 @@
|
|
|
1
|
+
import { toBlob } from "html-to-image";
|
|
2
|
+
import {
|
|
3
|
+
ArrowUpRight,
|
|
4
|
+
Bot,
|
|
5
|
+
CheckCircle2,
|
|
6
|
+
ChevronDown,
|
|
7
|
+
FileUp,
|
|
8
|
+
Maximize2,
|
|
9
|
+
LoaderCircle,
|
|
10
|
+
MessageSquarePlus,
|
|
11
|
+
MousePointer2,
|
|
12
|
+
PenLine,
|
|
13
|
+
Redo2,
|
|
14
|
+
Send,
|
|
15
|
+
Square,
|
|
16
|
+
StopCircle,
|
|
17
|
+
Trash2,
|
|
18
|
+
Type,
|
|
19
|
+
Undo2,
|
|
20
|
+
Video,
|
|
21
|
+
X,
|
|
22
|
+
} from "lucide-solid";
|
|
23
|
+
import {
|
|
24
|
+
For,
|
|
25
|
+
Show,
|
|
26
|
+
createEffect,
|
|
27
|
+
createSignal,
|
|
28
|
+
onCleanup,
|
|
29
|
+
onMount,
|
|
30
|
+
type Component,
|
|
31
|
+
type JSX,
|
|
32
|
+
} from "solid-js";
|
|
33
|
+
import type {
|
|
34
|
+
FeedbackLocator,
|
|
35
|
+
FeedbackLocatorAgentSessionResult,
|
|
36
|
+
FeedbackLocatorBounds,
|
|
37
|
+
FeedbackLocatorCodexThreadResult,
|
|
38
|
+
FeedbackLocatorContextPreview,
|
|
39
|
+
FeedbackLocatorHotkey,
|
|
40
|
+
FeedbackLocatorOpenCodeThreadResult,
|
|
41
|
+
FeedbackLocatorPoint,
|
|
42
|
+
FeedbackLocatorSourceMetadata,
|
|
43
|
+
FeedbackLocatorSubmitResult,
|
|
44
|
+
} from "./types";
|
|
45
|
+
import { createShortcutResolver } from "./shortcuts";
|
|
46
|
+
|
|
47
|
+
type FeedbackLocatorRootProps = {
|
|
48
|
+
locator: FeedbackLocator;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type FeedbackLocatorMode = "idle" | "selecting" | "annotating" | "submitting";
|
|
52
|
+
type FeedbackLocatorSubmitTarget = "linear" | "agent" | "codex" | "opencode";
|
|
53
|
+
type AnnotationTool = "select" | "pen" | "rect" | "arrow" | "text";
|
|
54
|
+
|
|
55
|
+
type PenAnnotation = {
|
|
56
|
+
type: "pen";
|
|
57
|
+
points: FeedbackLocatorPoint[];
|
|
58
|
+
color: string;
|
|
59
|
+
width: number;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
type RectAnnotation = {
|
|
63
|
+
type: "rect";
|
|
64
|
+
start: FeedbackLocatorPoint;
|
|
65
|
+
end: FeedbackLocatorPoint;
|
|
66
|
+
color: string;
|
|
67
|
+
width: number;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
type ArrowAnnotation = {
|
|
71
|
+
type: "arrow";
|
|
72
|
+
start: FeedbackLocatorPoint;
|
|
73
|
+
end: FeedbackLocatorPoint;
|
|
74
|
+
color: string;
|
|
75
|
+
width: number;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
type TextAnnotation = {
|
|
79
|
+
type: "text";
|
|
80
|
+
point: FeedbackLocatorPoint;
|
|
81
|
+
text: string;
|
|
82
|
+
color: string;
|
|
83
|
+
fontSize: number;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
type Annotation = PenAnnotation | RectAnnotation | ArrowAnnotation | TextAnnotation;
|
|
87
|
+
|
|
88
|
+
type AnnotationBounds = {
|
|
89
|
+
left: number;
|
|
90
|
+
top: number;
|
|
91
|
+
right: number;
|
|
92
|
+
bottom: number;
|
|
93
|
+
width: number;
|
|
94
|
+
height: number;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
type ScreenshotState = {
|
|
98
|
+
targetElement: HTMLElement;
|
|
99
|
+
source: FeedbackLocatorSourceMetadata | null;
|
|
100
|
+
originalBlob: Blob;
|
|
101
|
+
originalUrl: string;
|
|
102
|
+
width: number;
|
|
103
|
+
height: number;
|
|
104
|
+
captureMode: "element" | "page";
|
|
105
|
+
targetHighlight?: RectAnnotation;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
type TextDraft = {
|
|
109
|
+
point: FeedbackLocatorPoint;
|
|
110
|
+
value: string;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
type AnnotationDragState = {
|
|
114
|
+
annotationIndex: number;
|
|
115
|
+
previousPoint: FeedbackLocatorPoint;
|
|
116
|
+
originalAnnotation: Annotation;
|
|
117
|
+
resizeHandle?: AnnotationResizeHandle;
|
|
118
|
+
didMove: boolean;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
type AnnotationResizeHandle = "nw" | "ne" | "sw" | "se" | "start" | "end";
|
|
122
|
+
|
|
123
|
+
type AnnotationHistoryEntry =
|
|
124
|
+
| {
|
|
125
|
+
type: "add";
|
|
126
|
+
annotation: Annotation;
|
|
127
|
+
}
|
|
128
|
+
| {
|
|
129
|
+
type: "delete";
|
|
130
|
+
annotation: Annotation;
|
|
131
|
+
index: number;
|
|
132
|
+
}
|
|
133
|
+
| {
|
|
134
|
+
type: "clear";
|
|
135
|
+
annotations: Annotation[];
|
|
136
|
+
}
|
|
137
|
+
| {
|
|
138
|
+
type: "update";
|
|
139
|
+
index: number;
|
|
140
|
+
before: Annotation;
|
|
141
|
+
after: Annotation;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
type PreparedFeedbackSubmission = {
|
|
145
|
+
formData: FormData;
|
|
146
|
+
metadata: Awaited<ReturnType<FeedbackLocator["createMetadata"]>>;
|
|
147
|
+
originalScreenshot: Blob;
|
|
148
|
+
annotatedScreenshot?: Blob;
|
|
149
|
+
recording?: File;
|
|
150
|
+
attachments?: File[];
|
|
151
|
+
exportAnnotations: Annotation[];
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const annotationColor = "#f59e0b";
|
|
155
|
+
const annotationWidth = 4;
|
|
156
|
+
const selectionColor = "#38bdf8";
|
|
157
|
+
const hitTolerance = 14;
|
|
158
|
+
const resizeHandleSize = 10;
|
|
159
|
+
const annotationColorOptions = ["#f59e0b", "#ef4444", "#22c55e", "#38bdf8", "#f8fafc"];
|
|
160
|
+
type AnnotationShortcutAction =
|
|
161
|
+
| "select"
|
|
162
|
+
| "pen"
|
|
163
|
+
| "arrow"
|
|
164
|
+
| "rect"
|
|
165
|
+
| "text"
|
|
166
|
+
| "undo"
|
|
167
|
+
| "redo"
|
|
168
|
+
| "delete"
|
|
169
|
+
| "clear"
|
|
170
|
+
| "close";
|
|
171
|
+
|
|
172
|
+
const annotationShortcutResolver = createShortcutResolver<AnnotationShortcutAction>([
|
|
173
|
+
{ action: "select", key: "v" },
|
|
174
|
+
{ action: "pen", key: "p" },
|
|
175
|
+
{ action: "arrow", key: "a" },
|
|
176
|
+
{ action: "rect", key: "r" },
|
|
177
|
+
{ action: "text", key: "t" },
|
|
178
|
+
{ action: "undo", key: "z", meta: true },
|
|
179
|
+
{ action: "undo", key: "z", ctrl: true },
|
|
180
|
+
{ action: "redo", key: "z", meta: true, shift: true },
|
|
181
|
+
{ action: "redo", key: "z", ctrl: true, shift: true },
|
|
182
|
+
{ action: "redo", key: "y", meta: true },
|
|
183
|
+
{ action: "redo", key: "y", ctrl: true },
|
|
184
|
+
{ action: "delete", key: "backspace" },
|
|
185
|
+
{ action: "delete", key: "delete" },
|
|
186
|
+
{ action: "clear", key: "k", meta: true, shift: true },
|
|
187
|
+
{ action: "clear", key: "k", ctrl: true, shift: true },
|
|
188
|
+
{ action: "close", key: "escape" },
|
|
189
|
+
]);
|
|
190
|
+
|
|
191
|
+
export const FeedbackLocatorRoot: Component<FeedbackLocatorRootProps> = (props) => {
|
|
192
|
+
let canvasRef: HTMLCanvasElement | undefined;
|
|
193
|
+
let textInputRef: HTMLInputElement | undefined;
|
|
194
|
+
let annotationDragState: AnnotationDragState | null = null;
|
|
195
|
+
|
|
196
|
+
const [mode, setMode] = createSignal<FeedbackLocatorMode>("idle");
|
|
197
|
+
const [hoverRect, setHoverRect] = createSignal<FeedbackLocatorBounds | null>(null);
|
|
198
|
+
const [selected, setSelected] = createSignal<ScreenshotState | null>(null);
|
|
199
|
+
const [annotations, setAnnotations] = createSignal<Annotation[]>([]);
|
|
200
|
+
const [annotationHistory, setAnnotationHistory] = createSignal<AnnotationHistoryEntry[]>([]);
|
|
201
|
+
const [redoStack, setRedoStack] = createSignal<AnnotationHistoryEntry[]>([]);
|
|
202
|
+
const [draft, setDraft] = createSignal<Annotation | null>(null);
|
|
203
|
+
const [textDraft, setTextDraft] = createSignal<TextDraft | null>(null);
|
|
204
|
+
const [tool, setTool] = createSignal<AnnotationTool>("pen");
|
|
205
|
+
const [activeAnnotationColor, setActiveAnnotationColor] = createSignal(annotationColor);
|
|
206
|
+
const [activeAnnotationWidth, setActiveAnnotationWidth] = createSignal(annotationWidth);
|
|
207
|
+
const [selectedAnnotationIndex, setSelectedAnnotationIndex] = createSignal<number | null>(null);
|
|
208
|
+
const [isMovingAnnotation, setIsMovingAnnotation] = createSignal(false);
|
|
209
|
+
const [contextPreview, setContextPreview] = createSignal<FeedbackLocatorContextPreview | null>(
|
|
210
|
+
null,
|
|
211
|
+
);
|
|
212
|
+
const [prompt, setPrompt] = createSignal("");
|
|
213
|
+
const [attachments, setAttachments] = createSignal<File[]>([]);
|
|
214
|
+
const [recordingState, setRecordingState] = createSignal<"idle" | "recording" | "ready">(
|
|
215
|
+
"idle",
|
|
216
|
+
);
|
|
217
|
+
const [recordingBlob, setRecordingBlob] = createSignal<Blob | null>(null);
|
|
218
|
+
const [recordingUrl, setRecordingUrl] = createSignal<string | null>(null);
|
|
219
|
+
const [recordingDurationMs, setRecordingDurationMs] = createSignal<number | null>(null);
|
|
220
|
+
const [error, setError] = createSignal<string | null>(null);
|
|
221
|
+
const [submitTarget, setSubmitTarget] = createSignal<FeedbackLocatorSubmitTarget | null>(null);
|
|
222
|
+
const [linearResult, setLinearResult] = createSignal<FeedbackLocatorSubmitResult | null>(null);
|
|
223
|
+
const [agentSession, setAgentSession] =
|
|
224
|
+
createSignal<FeedbackLocatorAgentSessionResult | null>(null);
|
|
225
|
+
const [codexThread, setCodexThread] =
|
|
226
|
+
createSignal<FeedbackLocatorCodexThreadResult | null>(null);
|
|
227
|
+
const [openCodeThread, setOpenCodeThread] =
|
|
228
|
+
createSignal<FeedbackLocatorOpenCodeThreadResult | null>(null);
|
|
229
|
+
let mediaRecorder: MediaRecorder | undefined;
|
|
230
|
+
let recordingStream: MediaStream | undefined;
|
|
231
|
+
let recordingStartedAt = 0;
|
|
232
|
+
let recordingChunks: Blob[] = [];
|
|
233
|
+
let discardRecordingOnStop = false;
|
|
234
|
+
|
|
235
|
+
onMount(() => {
|
|
236
|
+
const handleHotkey = (event: KeyboardEvent) => {
|
|
237
|
+
if (isEditableTarget(event.target)) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (matchesHotkey(event, props.locator.config.hotkey!)) {
|
|
242
|
+
event.preventDefault();
|
|
243
|
+
startSelection();
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
document.addEventListener("keydown", handleHotkey);
|
|
248
|
+
onCleanup(() => {
|
|
249
|
+
document.removeEventListener("keydown", handleHotkey);
|
|
250
|
+
stopRecordingStream();
|
|
251
|
+
revokeRecordingUrl();
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
createEffect(() => {
|
|
256
|
+
if (mode() !== "selecting") {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const previousCursor = document.body.style.cursor;
|
|
261
|
+
document.body.style.cursor = "crosshair";
|
|
262
|
+
|
|
263
|
+
const handlePointerMove = (event: PointerEvent) => {
|
|
264
|
+
const element = findSelectableElement(event.target);
|
|
265
|
+
setHoverRect(element ? rectToBounds(element.getBoundingClientRect()) : null);
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const handleClick = async (event: MouseEvent) => {
|
|
269
|
+
const element = findSelectableElement(event.target);
|
|
270
|
+
|
|
271
|
+
if (!element) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
event.preventDefault();
|
|
276
|
+
event.stopPropagation();
|
|
277
|
+
await captureElement(element);
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const handleKeydown = (event: KeyboardEvent) => {
|
|
281
|
+
if (event.key === "Escape") {
|
|
282
|
+
event.preventDefault();
|
|
283
|
+
reset();
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
document.addEventListener("pointermove", handlePointerMove, true);
|
|
288
|
+
document.addEventListener("click", handleClick, true);
|
|
289
|
+
document.addEventListener("keydown", handleKeydown, true);
|
|
290
|
+
|
|
291
|
+
onCleanup(() => {
|
|
292
|
+
document.body.style.cursor = previousCursor;
|
|
293
|
+
document.removeEventListener("pointermove", handlePointerMove, true);
|
|
294
|
+
document.removeEventListener("click", handleClick, true);
|
|
295
|
+
document.removeEventListener("keydown", handleKeydown, true);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
createEffect(() => {
|
|
300
|
+
annotations();
|
|
301
|
+
draft();
|
|
302
|
+
selectedAnnotationIndex();
|
|
303
|
+
selected();
|
|
304
|
+
redrawOverlay();
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
createEffect(() => {
|
|
308
|
+
const index = selectedAnnotationIndex();
|
|
309
|
+
const annotation = index === null ? null : annotations()[index] ?? null;
|
|
310
|
+
if (!annotation) {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
setActiveAnnotationColor(annotation.color);
|
|
315
|
+
if ("width" in annotation) {
|
|
316
|
+
setActiveAnnotationWidth(annotation.width);
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
createEffect(() => {
|
|
321
|
+
if (textDraft() && textInputRef) {
|
|
322
|
+
queueMicrotask(() => textInputRef?.focus());
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
createEffect(() => {
|
|
327
|
+
if (mode() !== "annotating") {
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const handleKeydown = (event: KeyboardEvent) => {
|
|
332
|
+
if (isEditableTarget(event.target)) {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const action = annotationShortcutResolver(event);
|
|
337
|
+
|
|
338
|
+
if (!action) {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
event.preventDefault();
|
|
343
|
+
|
|
344
|
+
if (action === "select") {
|
|
345
|
+
setTool("select");
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (action === "pen") {
|
|
350
|
+
setTool("pen");
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (action === "arrow") {
|
|
355
|
+
setTool("arrow");
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (action === "rect") {
|
|
360
|
+
setTool("rect");
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (action === "text") {
|
|
365
|
+
setTool("text");
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (action === "redo") {
|
|
370
|
+
redo();
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (action === "undo") {
|
|
375
|
+
undo();
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (action === "delete") {
|
|
380
|
+
deleteSelectedAnnotation();
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (action === "clear") {
|
|
385
|
+
clearAnnotations();
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (action === "close") {
|
|
390
|
+
reset();
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
document.addEventListener("keydown", handleKeydown);
|
|
395
|
+
onCleanup(() => document.removeEventListener("keydown", handleKeydown));
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
function startSelection() {
|
|
399
|
+
setError(null);
|
|
400
|
+
setSubmitTarget(null);
|
|
401
|
+
setLinearResult(null);
|
|
402
|
+
setAgentSession(null);
|
|
403
|
+
setCodexThread(null);
|
|
404
|
+
setSelected(null);
|
|
405
|
+
setAnnotations([]);
|
|
406
|
+
setAnnotationHistory([]);
|
|
407
|
+
setRedoStack([]);
|
|
408
|
+
setDraft(null);
|
|
409
|
+
setTextDraft(null);
|
|
410
|
+
setSelectedAnnotationIndex(null);
|
|
411
|
+
setContextPreview(null);
|
|
412
|
+
annotationDragState = null;
|
|
413
|
+
setIsMovingAnnotation(false);
|
|
414
|
+
setPrompt("");
|
|
415
|
+
setAttachments([]);
|
|
416
|
+
clearRecording();
|
|
417
|
+
setMode("selecting");
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function reset() {
|
|
421
|
+
const current = selected();
|
|
422
|
+
if (current) {
|
|
423
|
+
URL.revokeObjectURL(current.originalUrl);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
setMode("idle");
|
|
427
|
+
setHoverRect(null);
|
|
428
|
+
setSelected(null);
|
|
429
|
+
setAnnotations([]);
|
|
430
|
+
setAnnotationHistory([]);
|
|
431
|
+
setRedoStack([]);
|
|
432
|
+
setDraft(null);
|
|
433
|
+
setTextDraft(null);
|
|
434
|
+
setSelectedAnnotationIndex(null);
|
|
435
|
+
setContextPreview(null);
|
|
436
|
+
annotationDragState = null;
|
|
437
|
+
setIsMovingAnnotation(false);
|
|
438
|
+
setPrompt("");
|
|
439
|
+
setAttachments([]);
|
|
440
|
+
clearRecording();
|
|
441
|
+
setError(null);
|
|
442
|
+
setSubmitTarget(null);
|
|
443
|
+
setLinearResult(null);
|
|
444
|
+
setAgentSession(null);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async function captureElement(element: HTMLElement) {
|
|
448
|
+
setError(null);
|
|
449
|
+
setHoverRect(null);
|
|
450
|
+
|
|
451
|
+
try {
|
|
452
|
+
const source = props.locator.config.sourceCollector?.(element) ?? null;
|
|
453
|
+
const blob = await toBlob(element, {
|
|
454
|
+
cacheBust: true,
|
|
455
|
+
pixelRatio: Math.min(window.devicePixelRatio || 1, 2),
|
|
456
|
+
backgroundColor: getComputedStyle(document.body).backgroundColor,
|
|
457
|
+
filter: (node) =>
|
|
458
|
+
node instanceof HTMLElement ? !node.closest("[data-feedback-locator-ui='true']") : true,
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
if (!blob) {
|
|
462
|
+
throw new Error("Screenshot kon niet worden gemaakt.");
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const imageSize = await getImageSize(blob);
|
|
466
|
+
setSelected({
|
|
467
|
+
targetElement: element,
|
|
468
|
+
source,
|
|
469
|
+
originalBlob: blob,
|
|
470
|
+
originalUrl: URL.createObjectURL(blob),
|
|
471
|
+
width: imageSize.width,
|
|
472
|
+
height: imageSize.height,
|
|
473
|
+
captureMode: "element",
|
|
474
|
+
});
|
|
475
|
+
void refreshContextPreview(element);
|
|
476
|
+
setMode("annotating");
|
|
477
|
+
} catch (captureError) {
|
|
478
|
+
setMode("idle");
|
|
479
|
+
setError(
|
|
480
|
+
captureError instanceof Error
|
|
481
|
+
? captureError.message
|
|
482
|
+
: "Screenshot kon niet worden gemaakt.",
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function captureFullPageWithTarget() {
|
|
488
|
+
const current = selected();
|
|
489
|
+
if (!current) {
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
setError(null);
|
|
494
|
+
|
|
495
|
+
try {
|
|
496
|
+
const source = current.source;
|
|
497
|
+
const targetElement = current.targetElement;
|
|
498
|
+
const targetDocument = document.documentElement;
|
|
499
|
+
const blob = await toBlob(targetDocument, {
|
|
500
|
+
cacheBust: true,
|
|
501
|
+
pixelRatio: Math.min(window.devicePixelRatio || 1, 2),
|
|
502
|
+
backgroundColor: getComputedStyle(document.body).backgroundColor,
|
|
503
|
+
width: targetDocument.scrollWidth,
|
|
504
|
+
height: targetDocument.scrollHeight,
|
|
505
|
+
style: {
|
|
506
|
+
transform: "none",
|
|
507
|
+
transformOrigin: "top left",
|
|
508
|
+
},
|
|
509
|
+
filter: (node) =>
|
|
510
|
+
node instanceof HTMLElement ? !node.closest("[data-feedback-locator-ui='true']") : true,
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
if (!blob) {
|
|
514
|
+
throw new Error("Volledige pagina kon niet worden vastgelegd.");
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const imageSize = await getImageSize(blob);
|
|
518
|
+
const targetRect = targetElement.getBoundingClientRect();
|
|
519
|
+
const scaleX = imageSize.width / Math.max(targetDocument.scrollWidth, 1);
|
|
520
|
+
const scaleY = imageSize.height / Math.max(targetDocument.scrollHeight, 1);
|
|
521
|
+
const start = {
|
|
522
|
+
x: (targetRect.left + window.scrollX) * scaleX,
|
|
523
|
+
y: (targetRect.top + window.scrollY) * scaleY,
|
|
524
|
+
};
|
|
525
|
+
const end = {
|
|
526
|
+
x: (targetRect.right + window.scrollX) * scaleX,
|
|
527
|
+
y: (targetRect.bottom + window.scrollY) * scaleY,
|
|
528
|
+
};
|
|
529
|
+
const targetHighlight: RectAnnotation = {
|
|
530
|
+
type: "rect",
|
|
531
|
+
start,
|
|
532
|
+
end,
|
|
533
|
+
color: selectionColor,
|
|
534
|
+
width: Math.max(annotationWidth, 5),
|
|
535
|
+
};
|
|
536
|
+
const highlightedBlob = await drawTargetHighlightOnBlob(blob, imageSize, targetHighlight);
|
|
537
|
+
|
|
538
|
+
URL.revokeObjectURL(current.originalUrl);
|
|
539
|
+
setSelected({
|
|
540
|
+
...current,
|
|
541
|
+
source,
|
|
542
|
+
originalBlob: highlightedBlob,
|
|
543
|
+
originalUrl: URL.createObjectURL(highlightedBlob),
|
|
544
|
+
width: imageSize.width,
|
|
545
|
+
height: imageSize.height,
|
|
546
|
+
captureMode: "page",
|
|
547
|
+
targetHighlight,
|
|
548
|
+
});
|
|
549
|
+
setAnnotationHistory([]);
|
|
550
|
+
setRedoStack([]);
|
|
551
|
+
setSelectedAnnotationIndex(null);
|
|
552
|
+
void refreshContextPreview(targetElement);
|
|
553
|
+
} catch (captureError) {
|
|
554
|
+
setError(
|
|
555
|
+
captureError instanceof Error
|
|
556
|
+
? captureError.message
|
|
557
|
+
: "Volledige pagina kon niet worden vastgelegd.",
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async function prepareFeedbackSubmission(): Promise<PreparedFeedbackSubmission | null> {
|
|
563
|
+
const screenshot = selected();
|
|
564
|
+
const cleanPrompt = prompt().trim();
|
|
565
|
+
|
|
566
|
+
if (!screenshot || !cleanPrompt) {
|
|
567
|
+
setError("Vul eerst je feedback in.");
|
|
568
|
+
return null;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const exportAnnotations = collectExportAnnotations();
|
|
572
|
+
const annotatedScreenshot =
|
|
573
|
+
exportAnnotations.length > 0
|
|
574
|
+
? await exportAnnotatedScreenshot(screenshot, exportAnnotations)
|
|
575
|
+
: undefined;
|
|
576
|
+
const finalScreenshot = annotatedScreenshot ?? screenshot.originalBlob;
|
|
577
|
+
const selectedRecording = createRecordingFile();
|
|
578
|
+
const metadata = await props.locator.createMetadata({
|
|
579
|
+
prompt: cleanPrompt,
|
|
580
|
+
targetElement: screenshot.targetElement,
|
|
581
|
+
source: screenshot.source,
|
|
582
|
+
});
|
|
583
|
+
const selectedAttachments = attachments();
|
|
584
|
+
metadata.context.feedbackLocator = {
|
|
585
|
+
...((metadata.context.feedbackLocator as Record<string, unknown> | undefined) ?? {}),
|
|
586
|
+
captureMode: screenshot.captureMode,
|
|
587
|
+
attachments: selectedAttachments.map((file) => ({
|
|
588
|
+
name: file.name,
|
|
589
|
+
type: file.type || "application/octet-stream",
|
|
590
|
+
size: file.size,
|
|
591
|
+
})),
|
|
592
|
+
recording: selectedRecording
|
|
593
|
+
? {
|
|
594
|
+
name: selectedRecording.name,
|
|
595
|
+
type: selectedRecording.type || "video/webm",
|
|
596
|
+
size: selectedRecording.size,
|
|
597
|
+
durationMs: recordingDurationMs(),
|
|
598
|
+
}
|
|
599
|
+
: null,
|
|
600
|
+
};
|
|
601
|
+
const formData = new FormData();
|
|
602
|
+
|
|
603
|
+
formData.append("metadata", JSON.stringify(metadata));
|
|
604
|
+
formData.append("screenshot", finalScreenshot, "screenshot.png");
|
|
605
|
+
if (selectedRecording) {
|
|
606
|
+
formData.append("recording", selectedRecording, selectedRecording.name);
|
|
607
|
+
}
|
|
608
|
+
for (const attachment of selectedAttachments) {
|
|
609
|
+
formData.append("attachments", attachment, attachment.name);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
return {
|
|
613
|
+
formData,
|
|
614
|
+
metadata,
|
|
615
|
+
originalScreenshot: finalScreenshot,
|
|
616
|
+
annotatedScreenshot,
|
|
617
|
+
recording: selectedRecording ?? undefined,
|
|
618
|
+
attachments: selectedAttachments,
|
|
619
|
+
exportAnnotations,
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function applySubmissionAnnotations(prepared: PreparedFeedbackSubmission) {
|
|
624
|
+
setAnnotations(prepared.exportAnnotations);
|
|
625
|
+
setAnnotationHistory([]);
|
|
626
|
+
setDraft(null);
|
|
627
|
+
setTextDraft(null);
|
|
628
|
+
setRedoStack([]);
|
|
629
|
+
setSelectedAnnotationIndex(null);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async function submitFeedback() {
|
|
633
|
+
await runSubmission("linear", async (prepared) => {
|
|
634
|
+
const pendingWindow = openPendingExternalWindow(
|
|
635
|
+
"Linear issue aanmaken...",
|
|
636
|
+
"De feedback wordt verwerkt en de Linear issue wordt geopend.",
|
|
637
|
+
);
|
|
638
|
+
|
|
639
|
+
try {
|
|
640
|
+
const submitResult = await props.locator.config.submitFeedback(prepared);
|
|
641
|
+
setLinearResult(submitResult);
|
|
642
|
+
openExternalWindow(submitResult.issueUrl, pendingWindow);
|
|
643
|
+
} catch (submitError) {
|
|
644
|
+
closePendingWindow(pendingWindow);
|
|
645
|
+
throw submitError;
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
async function startAgentSession() {
|
|
651
|
+
const startAgentSessionAction = props.locator.config.startAgentSession;
|
|
652
|
+
|
|
653
|
+
if (!startAgentSessionAction) {
|
|
654
|
+
setError("Agent sessies zijn nog niet gekoppeld voor deze app.");
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
await runSubmission("agent", async (prepared) => {
|
|
659
|
+
const pendingWindow = openPendingAgentSessionWindow();
|
|
660
|
+
const session = await startAgentSessionAction(prepared);
|
|
661
|
+
setAgentSession(session);
|
|
662
|
+
openAgentSessionWindow(session, pendingWindow);
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
async function startCodexThread() {
|
|
667
|
+
const startCodexThreadAction = props.locator.config.startCodexThread;
|
|
668
|
+
|
|
669
|
+
if (!startCodexThreadAction) {
|
|
670
|
+
setError("Codex threads zijn nog niet gekoppeld voor deze app.");
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
await runSubmission("codex", async (prepared) => {
|
|
675
|
+
const thread = await startCodexThreadAction(prepared);
|
|
676
|
+
setCodexThread(thread);
|
|
677
|
+
|
|
678
|
+
if (thread.linearIssue) {
|
|
679
|
+
openExternalWindow(thread.linearIssue.issueUrl);
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
async function startOpenCodeThread() {
|
|
685
|
+
const startOpenCodeThreadAction = props.locator.config.startOpenCodeThread;
|
|
686
|
+
|
|
687
|
+
if (!startOpenCodeThreadAction) {
|
|
688
|
+
setError("OpenCode sessies zijn nog niet gekoppeld voor deze app.");
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
await runSubmission("opencode", async (prepared) => {
|
|
693
|
+
const thread = await startOpenCodeThreadAction(prepared);
|
|
694
|
+
setOpenCodeThread(thread);
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
async function runSubmission(
|
|
699
|
+
target: FeedbackLocatorSubmitTarget,
|
|
700
|
+
submit: (prepared: PreparedFeedbackSubmission) => Promise<void>,
|
|
701
|
+
) {
|
|
702
|
+
try {
|
|
703
|
+
setError(null);
|
|
704
|
+
setSubmitTarget(target);
|
|
705
|
+
setMode("submitting");
|
|
706
|
+
const prepared = await prepareFeedbackSubmission();
|
|
707
|
+
|
|
708
|
+
if (!prepared) {
|
|
709
|
+
setMode("annotating");
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
await submit(prepared);
|
|
714
|
+
applySubmissionAnnotations(prepared);
|
|
715
|
+
setMode("annotating");
|
|
716
|
+
} catch (submitError) {
|
|
717
|
+
setMode("annotating");
|
|
718
|
+
setError(
|
|
719
|
+
submitError instanceof Error ? submitError.message : "Feedback versturen is mislukt.",
|
|
720
|
+
);
|
|
721
|
+
} finally {
|
|
722
|
+
setSubmitTarget(null);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
async function refreshContextPreview(targetElement = selected()?.targetElement) {
|
|
727
|
+
if (!targetElement) {
|
|
728
|
+
setContextPreview(null);
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
try {
|
|
733
|
+
setContextPreview(await props.locator.collectContext({ targetElement }));
|
|
734
|
+
} catch (previewError) {
|
|
735
|
+
setContextPreview({
|
|
736
|
+
context: {},
|
|
737
|
+
contextErrors: [
|
|
738
|
+
previewError instanceof Error
|
|
739
|
+
? previewError.message
|
|
740
|
+
: "Context preview kon niet worden opgehaald.",
|
|
741
|
+
],
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
async function startRecording() {
|
|
747
|
+
if (!supportsScreenRecording()) {
|
|
748
|
+
setError("Video-opname wordt niet ondersteund in deze browser.");
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
try {
|
|
753
|
+
setError(null);
|
|
754
|
+
clearRecording();
|
|
755
|
+
const stream = await navigator.mediaDevices.getDisplayMedia({
|
|
756
|
+
video: true,
|
|
757
|
+
audio: false,
|
|
758
|
+
});
|
|
759
|
+
const mimeType = getSupportedRecordingMimeType();
|
|
760
|
+
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
|
|
761
|
+
|
|
762
|
+
recordingStream = stream;
|
|
763
|
+
mediaRecorder = recorder;
|
|
764
|
+
recordingChunks = [];
|
|
765
|
+
recordingStartedAt = Date.now();
|
|
766
|
+
|
|
767
|
+
recorder.addEventListener("dataavailable", (event) => {
|
|
768
|
+
if (event.data.size > 0) {
|
|
769
|
+
recordingChunks.push(event.data);
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
recorder.addEventListener("stop", () => {
|
|
773
|
+
const blob = new Blob(recordingChunks, {
|
|
774
|
+
type: recorder.mimeType || mimeType || "video/webm",
|
|
775
|
+
});
|
|
776
|
+
const shouldDiscard = discardRecordingOnStop;
|
|
777
|
+
discardRecordingOnStop = false;
|
|
778
|
+
stopRecordingStream();
|
|
779
|
+
|
|
780
|
+
if (shouldDiscard) {
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
revokeRecordingUrl();
|
|
785
|
+
setRecordingBlob(blob);
|
|
786
|
+
setRecordingUrl(URL.createObjectURL(blob));
|
|
787
|
+
setRecordingDurationMs(Math.max(0, Date.now() - recordingStartedAt));
|
|
788
|
+
setRecordingState("ready");
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
for (const track of stream.getTracks()) {
|
|
792
|
+
track.addEventListener("ended", stopRecording);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
recorder.start();
|
|
796
|
+
setRecordingState("recording");
|
|
797
|
+
} catch (recordingError) {
|
|
798
|
+
stopRecordingStream();
|
|
799
|
+
setRecordingState("idle");
|
|
800
|
+
|
|
801
|
+
if (recordingError instanceof DOMException && recordingError.name === "NotAllowedError") {
|
|
802
|
+
setError("Video-opname is geannuleerd.");
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
setError(
|
|
807
|
+
recordingError instanceof Error
|
|
808
|
+
? recordingError.message
|
|
809
|
+
: "Video-opname kon niet worden gestart.",
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function stopRecording() {
|
|
815
|
+
if (mediaRecorder && mediaRecorder.state !== "inactive") {
|
|
816
|
+
mediaRecorder.stop();
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
stopRecordingStream();
|
|
821
|
+
setRecordingState(recordingBlob() ? "ready" : "idle");
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function clearRecording() {
|
|
825
|
+
if (mediaRecorder && mediaRecorder.state !== "inactive") {
|
|
826
|
+
discardRecordingOnStop = true;
|
|
827
|
+
mediaRecorder.stop();
|
|
828
|
+
}
|
|
829
|
+
stopRecordingStream();
|
|
830
|
+
revokeRecordingUrl();
|
|
831
|
+
recordingChunks = [];
|
|
832
|
+
setRecordingBlob(null);
|
|
833
|
+
setRecordingUrl(null);
|
|
834
|
+
setRecordingDurationMs(null);
|
|
835
|
+
setRecordingState("idle");
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function createRecordingFile() {
|
|
839
|
+
const blob = recordingBlob();
|
|
840
|
+
|
|
841
|
+
if (!blob) {
|
|
842
|
+
return null;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
return new File([blob], "screen-recording.webm", {
|
|
846
|
+
type: blob.type || "video/webm",
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function stopRecordingStream() {
|
|
851
|
+
for (const track of recordingStream?.getTracks() ?? []) {
|
|
852
|
+
track.stop();
|
|
853
|
+
}
|
|
854
|
+
recordingStream = undefined;
|
|
855
|
+
mediaRecorder = undefined;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function revokeRecordingUrl() {
|
|
859
|
+
const url = recordingUrl();
|
|
860
|
+
if (url) {
|
|
861
|
+
URL.revokeObjectURL(url);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function handleCanvasPointerDown(event: PointerEvent) {
|
|
866
|
+
if (!canvasRef || mode() === "submitting" || textDraft()) {
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
const point = getCanvasPoint(event, canvasRef);
|
|
871
|
+
|
|
872
|
+
if (tool() === "select") {
|
|
873
|
+
const selectedIndex = selectedAnnotationIndex();
|
|
874
|
+
const selectedAnnotation =
|
|
875
|
+
selectedIndex === null ? null : annotations()[selectedIndex] ?? null;
|
|
876
|
+
const resizeHandle = selectedAnnotation
|
|
877
|
+
? findResizeHandleAtPoint(selectedAnnotation, point)
|
|
878
|
+
: null;
|
|
879
|
+
|
|
880
|
+
if (selectedIndex !== null && selectedAnnotation && resizeHandle) {
|
|
881
|
+
event.preventDefault();
|
|
882
|
+
canvasRef.setPointerCapture(event.pointerId);
|
|
883
|
+
annotationDragState = {
|
|
884
|
+
annotationIndex: selectedIndex,
|
|
885
|
+
previousPoint: point,
|
|
886
|
+
originalAnnotation: selectedAnnotation,
|
|
887
|
+
resizeHandle,
|
|
888
|
+
didMove: false,
|
|
889
|
+
};
|
|
890
|
+
setIsMovingAnnotation(true);
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const annotationIndex = findAnnotationAtPoint(annotations(), point);
|
|
895
|
+
setSelectedAnnotationIndex(annotationIndex);
|
|
896
|
+
const annotation = annotationIndex === null ? null : annotations()[annotationIndex] ?? null;
|
|
897
|
+
|
|
898
|
+
if (annotationIndex !== null && annotation) {
|
|
899
|
+
event.preventDefault();
|
|
900
|
+
canvasRef.setPointerCapture(event.pointerId);
|
|
901
|
+
annotationDragState = {
|
|
902
|
+
annotationIndex,
|
|
903
|
+
previousPoint: point,
|
|
904
|
+
originalAnnotation: annotation,
|
|
905
|
+
didMove: false,
|
|
906
|
+
};
|
|
907
|
+
setIsMovingAnnotation(true);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
setSelectedAnnotationIndex(null);
|
|
914
|
+
|
|
915
|
+
if (tool() === "text") {
|
|
916
|
+
setTextDraft({ point, value: "" });
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
canvasRef.setPointerCapture(event.pointerId);
|
|
921
|
+
|
|
922
|
+
if (tool() === "pen") {
|
|
923
|
+
setDraft({
|
|
924
|
+
type: "pen",
|
|
925
|
+
points: [point],
|
|
926
|
+
color: activeAnnotationColor(),
|
|
927
|
+
width: activeAnnotationWidth(),
|
|
928
|
+
});
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
if (tool() === "rect") {
|
|
933
|
+
setDraft({
|
|
934
|
+
type: "rect",
|
|
935
|
+
start: point,
|
|
936
|
+
end: point,
|
|
937
|
+
color: activeAnnotationColor(),
|
|
938
|
+
width: activeAnnotationWidth(),
|
|
939
|
+
});
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
setDraft({
|
|
944
|
+
type: "arrow",
|
|
945
|
+
start: point,
|
|
946
|
+
end: point,
|
|
947
|
+
color: activeAnnotationColor(),
|
|
948
|
+
width: activeAnnotationWidth(),
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function handleCanvasPointerMove(event: PointerEvent) {
|
|
953
|
+
if (!canvasRef) {
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const point = getCanvasPoint(event, canvasRef);
|
|
958
|
+
|
|
959
|
+
if (annotationDragState) {
|
|
960
|
+
const delta = {
|
|
961
|
+
x: point.x - annotationDragState.previousPoint.x,
|
|
962
|
+
y: point.y - annotationDragState.previousPoint.y,
|
|
963
|
+
};
|
|
964
|
+
|
|
965
|
+
if (delta.x !== 0 || delta.y !== 0) {
|
|
966
|
+
const annotationIndex = annotationDragState.annotationIndex;
|
|
967
|
+
setAnnotations((current) =>
|
|
968
|
+
current.map((annotation, index) =>
|
|
969
|
+
index === annotationIndex
|
|
970
|
+
? annotationDragState?.resizeHandle
|
|
971
|
+
? resizeAnnotation(annotation, annotationDragState.resizeHandle, point)
|
|
972
|
+
: translateAnnotation(annotation, delta)
|
|
973
|
+
: annotation,
|
|
974
|
+
),
|
|
975
|
+
);
|
|
976
|
+
annotationDragState = {
|
|
977
|
+
...annotationDragState,
|
|
978
|
+
previousPoint: point,
|
|
979
|
+
didMove: true,
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
if (!draft()) {
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
const activeDraft = draft();
|
|
991
|
+
|
|
992
|
+
if (!activeDraft) {
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
if (activeDraft.type === "pen") {
|
|
997
|
+
setDraft({
|
|
998
|
+
...activeDraft,
|
|
999
|
+
points: [...activeDraft.points, point],
|
|
1000
|
+
});
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
setDraft({
|
|
1005
|
+
...activeDraft,
|
|
1006
|
+
end: point,
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function handleCanvasPointerUp(event: PointerEvent) {
|
|
1011
|
+
if (!canvasRef) {
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
if (annotationDragState) {
|
|
1016
|
+
if (canvasRef.hasPointerCapture(event.pointerId)) {
|
|
1017
|
+
canvasRef.releasePointerCapture(event.pointerId);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
if (annotationDragState.didMove) {
|
|
1021
|
+
const after = annotations()[annotationDragState.annotationIndex];
|
|
1022
|
+
if (after) {
|
|
1023
|
+
setAnnotationHistory((history) => [
|
|
1024
|
+
...history,
|
|
1025
|
+
{
|
|
1026
|
+
type: "update",
|
|
1027
|
+
index: annotationDragState!.annotationIndex,
|
|
1028
|
+
before: annotationDragState!.originalAnnotation,
|
|
1029
|
+
after,
|
|
1030
|
+
},
|
|
1031
|
+
]);
|
|
1032
|
+
}
|
|
1033
|
+
setRedoStack([]);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
annotationDragState = null;
|
|
1037
|
+
setIsMovingAnnotation(false);
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
if (!draft()) {
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
if (canvasRef.hasPointerCapture(event.pointerId)) {
|
|
1046
|
+
canvasRef.releasePointerCapture(event.pointerId);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
const activeDraft = draft();
|
|
1050
|
+
setDraft(null);
|
|
1051
|
+
|
|
1052
|
+
if (!activeDraft || isTinyAnnotation(activeDraft)) {
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
pushAnnotation(activeDraft);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function pushAnnotation(annotation: Annotation) {
|
|
1060
|
+
setAnnotations((current) => [...current, annotation]);
|
|
1061
|
+
setAnnotationHistory((current) => [...current, { type: "add", annotation }]);
|
|
1062
|
+
setRedoStack([]);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function undo() {
|
|
1066
|
+
setSelectedAnnotationIndex(null);
|
|
1067
|
+
setAnnotationHistory((history) => {
|
|
1068
|
+
const entry = history.at(-1);
|
|
1069
|
+
if (!entry) {
|
|
1070
|
+
return history;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
applyUndoEntry(entry);
|
|
1074
|
+
setRedoStack((redo) => [...redo, entry]);
|
|
1075
|
+
return history.slice(0, -1);
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function redo() {
|
|
1080
|
+
setSelectedAnnotationIndex(null);
|
|
1081
|
+
setRedoStack((current) => {
|
|
1082
|
+
const entry = current.at(-1);
|
|
1083
|
+
if (entry) {
|
|
1084
|
+
applyRedoEntry(entry);
|
|
1085
|
+
setAnnotationHistory((history) => [...history, entry]);
|
|
1086
|
+
}
|
|
1087
|
+
return current.slice(0, -1);
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function clearAnnotations() {
|
|
1092
|
+
const current = annotations();
|
|
1093
|
+
if (current.length > 0) {
|
|
1094
|
+
setAnnotationHistory((history) => [...history, { type: "clear", annotations: current }]);
|
|
1095
|
+
setRedoStack([]);
|
|
1096
|
+
setAnnotations([]);
|
|
1097
|
+
}
|
|
1098
|
+
setDraft(null);
|
|
1099
|
+
setTextDraft(null);
|
|
1100
|
+
setSelectedAnnotationIndex(null);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function deleteSelectedAnnotation() {
|
|
1104
|
+
const index = selectedAnnotationIndex();
|
|
1105
|
+
const annotation = index === null ? undefined : annotations()[index];
|
|
1106
|
+
if (index === null || !annotation) {
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
setAnnotations((current) => current.filter((_, currentIndex) => currentIndex !== index));
|
|
1111
|
+
setAnnotationHistory((history) => [...history, { type: "delete", annotation, index }]);
|
|
1112
|
+
setRedoStack([]);
|
|
1113
|
+
setSelectedAnnotationIndex(null);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function applyUndoEntry(entry: AnnotationHistoryEntry) {
|
|
1117
|
+
if (entry.type === "add") {
|
|
1118
|
+
setAnnotations((current) => current.slice(0, -1));
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
if (entry.type === "delete") {
|
|
1123
|
+
setAnnotations((current) => insertAnnotationAt(current, entry.index, entry.annotation));
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
if (entry.type === "update") {
|
|
1128
|
+
setAnnotations((current) =>
|
|
1129
|
+
current.map((annotation, index) => (index === entry.index ? entry.before : annotation)),
|
|
1130
|
+
);
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
setAnnotations(entry.annotations);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function applyRedoEntry(entry: AnnotationHistoryEntry) {
|
|
1138
|
+
if (entry.type === "add") {
|
|
1139
|
+
setAnnotations((current) => [...current, entry.annotation]);
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
if (entry.type === "delete") {
|
|
1144
|
+
setAnnotations((current) => current.filter((_, index) => index !== entry.index));
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
if (entry.type === "update") {
|
|
1149
|
+
setAnnotations((current) =>
|
|
1150
|
+
current.map((annotation, index) => (index === entry.index ? entry.after : annotation)),
|
|
1151
|
+
);
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
setAnnotations([]);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
function insertAnnotationAt(
|
|
1159
|
+
current: Annotation[],
|
|
1160
|
+
index: number,
|
|
1161
|
+
annotation: Annotation,
|
|
1162
|
+
) {
|
|
1163
|
+
const next = current.slice();
|
|
1164
|
+
next.splice(Math.max(0, Math.min(index, next.length)), 0, annotation);
|
|
1165
|
+
return next;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function applyAnnotationColor(color: string) {
|
|
1169
|
+
setActiveAnnotationColor(color);
|
|
1170
|
+
updateSelectedAnnotation((annotation) => ({ ...annotation, color }));
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
function applyAnnotationWidth(width: number) {
|
|
1174
|
+
const nextWidth = Math.max(1, Math.min(12, Math.round(width)));
|
|
1175
|
+
setActiveAnnotationWidth(nextWidth);
|
|
1176
|
+
updateSelectedAnnotation((annotation) =>
|
|
1177
|
+
"width" in annotation ? { ...annotation, width: nextWidth } : annotation,
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
function updateSelectedAnnotation(updater: (annotation: Annotation) => Annotation) {
|
|
1182
|
+
const index = selectedAnnotationIndex();
|
|
1183
|
+
const before = index === null ? undefined : annotations()[index];
|
|
1184
|
+
if (index === null || !before) {
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
const after = updater(before);
|
|
1189
|
+
if (JSON.stringify(before) === JSON.stringify(after)) {
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
setAnnotations((current) =>
|
|
1194
|
+
current.map((annotation, currentIndex) => (currentIndex === index ? after : annotation)),
|
|
1195
|
+
);
|
|
1196
|
+
setAnnotationHistory((history) => [...history, { type: "update", index, before, after }]);
|
|
1197
|
+
setRedoStack([]);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
function placeTextAnnotation() {
|
|
1201
|
+
const current = textDraft();
|
|
1202
|
+
const value = current?.value.trim();
|
|
1203
|
+
|
|
1204
|
+
if (!current || !value) {
|
|
1205
|
+
setTextDraft(null);
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
pushAnnotation(createTextAnnotation(current, value, selected()?.width, activeAnnotationColor()));
|
|
1210
|
+
setTextDraft(null);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
function redrawOverlay() {
|
|
1214
|
+
if (!canvasRef) {
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
const canvas = canvasRef;
|
|
1219
|
+
const context = canvas.getContext("2d");
|
|
1220
|
+
if (!context) {
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
context.clearRect(0, 0, canvas.width, canvas.height);
|
|
1225
|
+
for (const annotation of annotations()) {
|
|
1226
|
+
drawAnnotation(context, annotation);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
const targetHighlight = selected()?.targetHighlight;
|
|
1230
|
+
if (targetHighlight) {
|
|
1231
|
+
drawAnnotation(context, targetHighlight);
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
const activeDraft = draft();
|
|
1235
|
+
if (activeDraft) {
|
|
1236
|
+
drawAnnotation(context, activeDraft);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
const selectedIndex = selectedAnnotationIndex();
|
|
1240
|
+
const selectedAnnotation = selectedIndex === null ? null : annotations()[selectedIndex];
|
|
1241
|
+
|
|
1242
|
+
if (selectedAnnotation) {
|
|
1243
|
+
drawSelection(context, selectedAnnotation);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
function collectExportAnnotations() {
|
|
1248
|
+
const exportAnnotations = [...annotations()];
|
|
1249
|
+
const targetHighlight = selected()?.targetHighlight;
|
|
1250
|
+
if (targetHighlight) {
|
|
1251
|
+
exportAnnotations.unshift(targetHighlight);
|
|
1252
|
+
}
|
|
1253
|
+
const activeDraft = draft();
|
|
1254
|
+
|
|
1255
|
+
if (activeDraft && !isTinyAnnotation(activeDraft)) {
|
|
1256
|
+
exportAnnotations.push(activeDraft);
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
const activeTextDraft = textDraft();
|
|
1260
|
+
const textValue = activeTextDraft?.value.trim();
|
|
1261
|
+
|
|
1262
|
+
if (activeTextDraft && textValue) {
|
|
1263
|
+
exportAnnotations.push(
|
|
1264
|
+
createTextAnnotation(activeTextDraft, textValue, selected()?.width, activeAnnotationColor()),
|
|
1265
|
+
);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
return exportAnnotations;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
async function exportAnnotatedScreenshot(
|
|
1272
|
+
screenshot: ScreenshotState,
|
|
1273
|
+
exportAnnotations: Annotation[],
|
|
1274
|
+
) {
|
|
1275
|
+
const image = await loadImage(screenshot.originalUrl);
|
|
1276
|
+
const canvas = document.createElement("canvas");
|
|
1277
|
+
canvas.width = screenshot.width;
|
|
1278
|
+
canvas.height = screenshot.height;
|
|
1279
|
+
|
|
1280
|
+
const context = canvas.getContext("2d");
|
|
1281
|
+
if (!context) {
|
|
1282
|
+
throw new Error("Annotatie canvas kon niet worden gemaakt.");
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
1286
|
+
for (const annotation of exportAnnotations) {
|
|
1287
|
+
drawAnnotation(context, annotation);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
return new Promise<Blob>((resolve, reject) => {
|
|
1291
|
+
canvas.toBlob((blob) => {
|
|
1292
|
+
if (blob) {
|
|
1293
|
+
resolve(blob);
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
reject(new Error("Geannoteerde screenshot kon niet worden opgeslagen."));
|
|
1297
|
+
}, "image/png");
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
async function drawTargetHighlightOnBlob(
|
|
1302
|
+
blob: Blob,
|
|
1303
|
+
imageSize: { width: number; height: number },
|
|
1304
|
+
targetHighlight: RectAnnotation,
|
|
1305
|
+
) {
|
|
1306
|
+
const objectUrl = URL.createObjectURL(blob);
|
|
1307
|
+
|
|
1308
|
+
try {
|
|
1309
|
+
const image = await loadImage(objectUrl);
|
|
1310
|
+
const canvas = document.createElement("canvas");
|
|
1311
|
+
canvas.width = imageSize.width;
|
|
1312
|
+
canvas.height = imageSize.height;
|
|
1313
|
+
|
|
1314
|
+
const context = canvas.getContext("2d");
|
|
1315
|
+
if (!context) {
|
|
1316
|
+
throw new Error("Highlight canvas kon niet worden gemaakt.");
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
1320
|
+
drawAnnotation(context, targetHighlight);
|
|
1321
|
+
|
|
1322
|
+
return await new Promise<Blob>((resolve, reject) => {
|
|
1323
|
+
canvas.toBlob((highlightedBlob) => {
|
|
1324
|
+
if (highlightedBlob) {
|
|
1325
|
+
resolve(highlightedBlob);
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
reject(new Error("Highlight kon niet worden opgeslagen."));
|
|
1329
|
+
}, "image/png");
|
|
1330
|
+
});
|
|
1331
|
+
} finally {
|
|
1332
|
+
URL.revokeObjectURL(objectUrl);
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
const snapshot = () => selected();
|
|
1337
|
+
const selectionRect = () => hoverRect();
|
|
1338
|
+
const currentTextDraft = () => textDraft();
|
|
1339
|
+
const canvasCursor = () => {
|
|
1340
|
+
if (tool() === "select") {
|
|
1341
|
+
return isMovingAnnotation() ? "grabbing" : "grab";
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
if (tool() === "text") {
|
|
1345
|
+
return "text";
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
return "crosshair";
|
|
1349
|
+
};
|
|
1350
|
+
|
|
1351
|
+
return (
|
|
1352
|
+
<div data-feedback-locator-ui="true">
|
|
1353
|
+
<Show when={mode() === "idle"}>
|
|
1354
|
+
<button
|
|
1355
|
+
type="button"
|
|
1356
|
+
class="fixed bottom-5 right-5 z-[2147483000] inline-flex h-11 items-center gap-2 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow-md transition hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
1357
|
+
onClick={startSelection}
|
|
1358
|
+
title="Feedback toevoegen (Ctrl+Shift+F)"
|
|
1359
|
+
>
|
|
1360
|
+
<MessageSquarePlus class="h-4 w-4" />
|
|
1361
|
+
Feedback
|
|
1362
|
+
</button>
|
|
1363
|
+
</Show>
|
|
1364
|
+
|
|
1365
|
+
<Show when={error() && mode() === "idle"}>
|
|
1366
|
+
<div class="fixed bottom-20 right-5 z-[2147483000] max-w-sm rounded-md border border-destructive/40 bg-background px-4 py-3 text-sm text-foreground shadow-md">
|
|
1367
|
+
{error()}
|
|
1368
|
+
</div>
|
|
1369
|
+
</Show>
|
|
1370
|
+
|
|
1371
|
+
<Show when={mode() === "selecting"}>
|
|
1372
|
+
<div class="fixed left-1/2 top-4 z-[2147483000] flex -translate-x-1/2 items-center gap-2 rounded-md border bg-background px-3 py-2 text-sm text-foreground shadow-md">
|
|
1373
|
+
<MousePointer2 class="h-4 w-4 text-primary" />
|
|
1374
|
+
Klik op het onderdeel waar je feedback over hebt.
|
|
1375
|
+
<button
|
|
1376
|
+
type="button"
|
|
1377
|
+
class="ml-2 rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
1378
|
+
onClick={reset}
|
|
1379
|
+
title="Annuleren"
|
|
1380
|
+
>
|
|
1381
|
+
<X class="h-4 w-4" />
|
|
1382
|
+
</button>
|
|
1383
|
+
</div>
|
|
1384
|
+
<Show when={selectionRect()}>
|
|
1385
|
+
{(rect) => (
|
|
1386
|
+
<div
|
|
1387
|
+
class="pointer-events-none fixed z-[2147482999] rounded-md border-2 border-primary bg-primary/10"
|
|
1388
|
+
style={{
|
|
1389
|
+
left: `${rect().left}px`,
|
|
1390
|
+
top: `${rect().top}px`,
|
|
1391
|
+
width: `${rect().width}px`,
|
|
1392
|
+
height: `${rect().height}px`,
|
|
1393
|
+
}}
|
|
1394
|
+
/>
|
|
1395
|
+
)}
|
|
1396
|
+
</Show>
|
|
1397
|
+
</Show>
|
|
1398
|
+
|
|
1399
|
+
<Show when={snapshot()}>
|
|
1400
|
+
{(screenshot) => (
|
|
1401
|
+
<div class="fixed inset-0 z-[2147483000] bg-background/95 text-foreground backdrop-blur-sm">
|
|
1402
|
+
<div class="flex h-full min-h-0 flex-col">
|
|
1403
|
+
<div class="flex items-center justify-between border-b bg-background px-4 py-3">
|
|
1404
|
+
<div>
|
|
1405
|
+
<h2 class="text-base font-semibold">Feedback locator</h2>
|
|
1406
|
+
<p class="text-sm text-muted-foreground">
|
|
1407
|
+
Annoteer de screenshot en beschrijf wat er moet gebeuren.
|
|
1408
|
+
</p>
|
|
1409
|
+
</div>
|
|
1410
|
+
<button
|
|
1411
|
+
type="button"
|
|
1412
|
+
class="rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
1413
|
+
onClick={reset}
|
|
1414
|
+
title="Sluiten"
|
|
1415
|
+
>
|
|
1416
|
+
<X class="h-5 w-5" />
|
|
1417
|
+
</button>
|
|
1418
|
+
</div>
|
|
1419
|
+
|
|
1420
|
+
<div class="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[minmax(0,1fr)_360px]">
|
|
1421
|
+
<div class="flex min-h-0 flex-col border-r bg-muted/30">
|
|
1422
|
+
<div class="flex flex-wrap items-center gap-2 border-b bg-background px-4 py-3">
|
|
1423
|
+
<ToolButton
|
|
1424
|
+
active={tool() === "select"}
|
|
1425
|
+
icon={MousePointer2}
|
|
1426
|
+
label="Selecteer"
|
|
1427
|
+
onClick={() => setTool("select")}
|
|
1428
|
+
/>
|
|
1429
|
+
<ToolButton
|
|
1430
|
+
active={tool() === "pen"}
|
|
1431
|
+
icon={PenLine}
|
|
1432
|
+
label="Pen"
|
|
1433
|
+
onClick={() => setTool("pen")}
|
|
1434
|
+
/>
|
|
1435
|
+
<ToolButton
|
|
1436
|
+
active={tool() === "arrow"}
|
|
1437
|
+
icon={ArrowUpRight}
|
|
1438
|
+
label="Pijl"
|
|
1439
|
+
onClick={() => setTool("arrow")}
|
|
1440
|
+
/>
|
|
1441
|
+
<ToolButton
|
|
1442
|
+
active={tool() === "rect"}
|
|
1443
|
+
icon={Square}
|
|
1444
|
+
label="Rechthoek"
|
|
1445
|
+
onClick={() => setTool("rect")}
|
|
1446
|
+
/>
|
|
1447
|
+
<ToolButton
|
|
1448
|
+
active={tool() === "text"}
|
|
1449
|
+
icon={Type}
|
|
1450
|
+
label="Tekst"
|
|
1451
|
+
onClick={() => setTool("text")}
|
|
1452
|
+
/>
|
|
1453
|
+
<ToolButton
|
|
1454
|
+
active={screenshot().captureMode === "page"}
|
|
1455
|
+
icon={Maximize2}
|
|
1456
|
+
label="Volledige pagina"
|
|
1457
|
+
onClick={captureFullPageWithTarget}
|
|
1458
|
+
/>
|
|
1459
|
+
<div class="flex items-center gap-1 rounded-md border bg-muted/40 px-2 py-1">
|
|
1460
|
+
<For each={annotationColorOptions}>
|
|
1461
|
+
{(color) => (
|
|
1462
|
+
<button
|
|
1463
|
+
type="button"
|
|
1464
|
+
class={`h-6 w-6 rounded-full border transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
|
|
1465
|
+
activeAnnotationColor() === color
|
|
1466
|
+
? "scale-110 border-primary ring-2 ring-primary/40"
|
|
1467
|
+
: "border-border"
|
|
1468
|
+
}`}
|
|
1469
|
+
style={{ "background-color": color }}
|
|
1470
|
+
title={`Kleur ${color}`}
|
|
1471
|
+
onClick={() => applyAnnotationColor(color)}
|
|
1472
|
+
/>
|
|
1473
|
+
)}
|
|
1474
|
+
</For>
|
|
1475
|
+
</div>
|
|
1476
|
+
<label class="flex h-9 items-center gap-2 rounded-md border bg-muted/40 px-2 text-xs text-muted-foreground">
|
|
1477
|
+
Dikte
|
|
1478
|
+
<input
|
|
1479
|
+
type="range"
|
|
1480
|
+
min="1"
|
|
1481
|
+
max="12"
|
|
1482
|
+
value={activeAnnotationWidth()}
|
|
1483
|
+
class="w-20 accent-primary"
|
|
1484
|
+
onInput={(event) =>
|
|
1485
|
+
setActiveAnnotationWidth(Number(event.currentTarget.value))
|
|
1486
|
+
}
|
|
1487
|
+
onChange={(event) =>
|
|
1488
|
+
applyAnnotationWidth(Number(event.currentTarget.value))
|
|
1489
|
+
}
|
|
1490
|
+
/>
|
|
1491
|
+
</label>
|
|
1492
|
+
<div class="ml-auto flex items-center gap-2">
|
|
1493
|
+
<IconButton
|
|
1494
|
+
disabled={annotationHistory().length === 0}
|
|
1495
|
+
icon={Undo2}
|
|
1496
|
+
label="Ongedaan maken"
|
|
1497
|
+
onClick={undo}
|
|
1498
|
+
/>
|
|
1499
|
+
<IconButton
|
|
1500
|
+
disabled={redoStack().length === 0}
|
|
1501
|
+
icon={Redo2}
|
|
1502
|
+
label="Opnieuw"
|
|
1503
|
+
onClick={redo}
|
|
1504
|
+
/>
|
|
1505
|
+
<IconButton
|
|
1506
|
+
disabled={annotations().length === 0}
|
|
1507
|
+
icon={Trash2}
|
|
1508
|
+
label="Alles wissen"
|
|
1509
|
+
onClick={clearAnnotations}
|
|
1510
|
+
/>
|
|
1511
|
+
</div>
|
|
1512
|
+
</div>
|
|
1513
|
+
|
|
1514
|
+
<div class="min-h-0 flex-1 overflow-auto p-4">
|
|
1515
|
+
<div class="mx-auto w-fit max-w-full">
|
|
1516
|
+
<div class="relative max-w-full overflow-hidden rounded-md border bg-background shadow-sm">
|
|
1517
|
+
<img
|
|
1518
|
+
src={screenshot().originalUrl}
|
|
1519
|
+
alt="Screenshot van geselecteerd onderdeel"
|
|
1520
|
+
class="block max-h-[calc(100vh-190px)] max-w-full object-contain"
|
|
1521
|
+
onLoad={() => {
|
|
1522
|
+
if (canvasRef) {
|
|
1523
|
+
canvasRef.width = screenshot().width;
|
|
1524
|
+
canvasRef.height = screenshot().height;
|
|
1525
|
+
redrawOverlay();
|
|
1526
|
+
}
|
|
1527
|
+
}}
|
|
1528
|
+
/>
|
|
1529
|
+
<canvas
|
|
1530
|
+
ref={canvasRef}
|
|
1531
|
+
class="absolute inset-0 h-full w-full touch-none"
|
|
1532
|
+
style={{ cursor: canvasCursor() }}
|
|
1533
|
+
width={screenshot().width}
|
|
1534
|
+
height={screenshot().height}
|
|
1535
|
+
onPointerDown={handleCanvasPointerDown}
|
|
1536
|
+
onPointerMove={handleCanvasPointerMove}
|
|
1537
|
+
onPointerUp={handleCanvasPointerUp}
|
|
1538
|
+
onPointerCancel={handleCanvasPointerUp}
|
|
1539
|
+
/>
|
|
1540
|
+
<Show when={currentTextDraft()}>
|
|
1541
|
+
{(text) => (
|
|
1542
|
+
<div
|
|
1543
|
+
class="absolute min-w-56 rounded-md border bg-background p-2 shadow-md"
|
|
1544
|
+
style={{
|
|
1545
|
+
left: `${(text().point.x / screenshot().width) * 100}%`,
|
|
1546
|
+
top: `${(text().point.y / screenshot().height) * 100}%`,
|
|
1547
|
+
}}
|
|
1548
|
+
>
|
|
1549
|
+
<input
|
|
1550
|
+
ref={textInputRef}
|
|
1551
|
+
class="h-9 w-full rounded-md border bg-background px-2 text-sm outline-none focus:ring-2 focus:ring-ring"
|
|
1552
|
+
value={text().value}
|
|
1553
|
+
placeholder="Tekst op screenshot"
|
|
1554
|
+
onInput={(event) =>
|
|
1555
|
+
setTextDraft({
|
|
1556
|
+
point: text().point,
|
|
1557
|
+
value: event.currentTarget.value,
|
|
1558
|
+
})
|
|
1559
|
+
}
|
|
1560
|
+
onKeyDown={(event) => {
|
|
1561
|
+
if (event.key === "Enter") {
|
|
1562
|
+
placeTextAnnotation();
|
|
1563
|
+
}
|
|
1564
|
+
if (event.key === "Escape") {
|
|
1565
|
+
setTextDraft(null);
|
|
1566
|
+
}
|
|
1567
|
+
}}
|
|
1568
|
+
/>
|
|
1569
|
+
<div class="mt-2 flex justify-end gap-2">
|
|
1570
|
+
<button
|
|
1571
|
+
type="button"
|
|
1572
|
+
class="rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-muted"
|
|
1573
|
+
onClick={() => setTextDraft(null)}
|
|
1574
|
+
>
|
|
1575
|
+
Annuleer
|
|
1576
|
+
</button>
|
|
1577
|
+
<button
|
|
1578
|
+
type="button"
|
|
1579
|
+
class="rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground"
|
|
1580
|
+
onClick={placeTextAnnotation}
|
|
1581
|
+
>
|
|
1582
|
+
Plaats
|
|
1583
|
+
</button>
|
|
1584
|
+
</div>
|
|
1585
|
+
</div>
|
|
1586
|
+
)}
|
|
1587
|
+
</Show>
|
|
1588
|
+
</div>
|
|
1589
|
+
</div>
|
|
1590
|
+
</div>
|
|
1591
|
+
</div>
|
|
1592
|
+
|
|
1593
|
+
<aside class="flex min-h-0 flex-col bg-background">
|
|
1594
|
+
<div class="space-y-4 overflow-auto p-4">
|
|
1595
|
+
<Show when={linearResult()}>
|
|
1596
|
+
{(issue) => (
|
|
1597
|
+
<div class="rounded-md border border-primary/40 bg-primary/10 p-3 text-sm">
|
|
1598
|
+
<div class="flex items-center gap-2 font-medium">
|
|
1599
|
+
<CheckCircle2 class="h-4 w-4 text-primary" />
|
|
1600
|
+
Linear issue aangemaakt
|
|
1601
|
+
</div>
|
|
1602
|
+
<a
|
|
1603
|
+
class="mt-2 inline-flex items-center gap-1 text-primary underline-offset-4 hover:underline"
|
|
1604
|
+
href={issue().issueUrl}
|
|
1605
|
+
target="_blank"
|
|
1606
|
+
rel="noreferrer"
|
|
1607
|
+
>
|
|
1608
|
+
{issue().issueIdentifier}
|
|
1609
|
+
<ArrowUpRight class="h-3.5 w-3.5" />
|
|
1610
|
+
</a>
|
|
1611
|
+
</div>
|
|
1612
|
+
)}
|
|
1613
|
+
</Show>
|
|
1614
|
+
|
|
1615
|
+
<Show when={agentSession()}>
|
|
1616
|
+
{(session) => (
|
|
1617
|
+
<div class="rounded-md border border-sky-500/40 bg-sky-500/10 p-3 text-sm">
|
|
1618
|
+
<div class="flex items-center gap-2 font-medium">
|
|
1619
|
+
<CheckCircle2 class="h-4 w-4 text-sky-500" />
|
|
1620
|
+
Agent sessie gestart
|
|
1621
|
+
</div>
|
|
1622
|
+
<Show
|
|
1623
|
+
when={session().sessionUrl}
|
|
1624
|
+
fallback={
|
|
1625
|
+
<div class="mt-2 text-muted-foreground">{session().sessionId}</div>
|
|
1626
|
+
}
|
|
1627
|
+
>
|
|
1628
|
+
{(url) => (
|
|
1629
|
+
<a
|
|
1630
|
+
class="mt-2 inline-flex items-center gap-1 text-sky-600 underline-offset-4 hover:underline"
|
|
1631
|
+
href={url()}
|
|
1632
|
+
target="_blank"
|
|
1633
|
+
rel="noreferrer"
|
|
1634
|
+
>
|
|
1635
|
+
{session().sessionTitle || session().sessionId}
|
|
1636
|
+
<ArrowUpRight class="h-3.5 w-3.5" />
|
|
1637
|
+
</a>
|
|
1638
|
+
)}
|
|
1639
|
+
</Show>
|
|
1640
|
+
</div>
|
|
1641
|
+
)}
|
|
1642
|
+
</Show>
|
|
1643
|
+
|
|
1644
|
+
<Show when={codexThread()}>
|
|
1645
|
+
{(thread) => (
|
|
1646
|
+
<div class="rounded-md border border-violet-500/40 bg-violet-500/10 p-3 text-sm">
|
|
1647
|
+
<div class="flex items-center gap-2 font-medium">
|
|
1648
|
+
<CheckCircle2 class="h-4 w-4 text-violet-500" />
|
|
1649
|
+
Codex thread gestart
|
|
1650
|
+
</div>
|
|
1651
|
+
<div class="mt-2 text-muted-foreground">
|
|
1652
|
+
{thread().threadTitle || thread().threadId}
|
|
1653
|
+
</div>
|
|
1654
|
+
<Show when={thread().linearIssue}>
|
|
1655
|
+
{(issue) => (
|
|
1656
|
+
<a
|
|
1657
|
+
class="mt-2 inline-flex items-center gap-1 text-violet-600 underline-offset-4 hover:underline"
|
|
1658
|
+
href={issue().issueUrl}
|
|
1659
|
+
target="_blank"
|
|
1660
|
+
rel="noreferrer"
|
|
1661
|
+
>
|
|
1662
|
+
Linear: {issue().issueIdentifier}
|
|
1663
|
+
<ArrowUpRight class="h-3.5 w-3.5" />
|
|
1664
|
+
</a>
|
|
1665
|
+
)}
|
|
1666
|
+
</Show>
|
|
1667
|
+
</div>
|
|
1668
|
+
)}
|
|
1669
|
+
</Show>
|
|
1670
|
+
|
|
1671
|
+
<Show when={openCodeThread()}>
|
|
1672
|
+
{(thread) => (
|
|
1673
|
+
<div class="rounded-md border border-emerald-500/40 bg-emerald-500/10 p-3 text-sm">
|
|
1674
|
+
<div class="flex items-center gap-2 font-medium">
|
|
1675
|
+
<CheckCircle2 class="h-4 w-4 text-emerald-500" />
|
|
1676
|
+
OpenCode sessie gestart
|
|
1677
|
+
</div>
|
|
1678
|
+
<div class="mt-2 text-muted-foreground">
|
|
1679
|
+
{thread().sessionTitle || thread().sessionId}
|
|
1680
|
+
</div>
|
|
1681
|
+
<Show when={thread().linearIssue}>
|
|
1682
|
+
{(issue) => (
|
|
1683
|
+
<a
|
|
1684
|
+
class="mt-2 inline-flex items-center gap-1 text-emerald-600 underline-offset-4 hover:underline"
|
|
1685
|
+
href={issue().issueUrl}
|
|
1686
|
+
target="_blank"
|
|
1687
|
+
rel="noreferrer"
|
|
1688
|
+
>
|
|
1689
|
+
Linear: {issue().issueIdentifier}
|
|
1690
|
+
<ArrowUpRight class="h-3.5 w-3.5" />
|
|
1691
|
+
</a>
|
|
1692
|
+
)}
|
|
1693
|
+
</Show>
|
|
1694
|
+
</div>
|
|
1695
|
+
)}
|
|
1696
|
+
</Show>
|
|
1697
|
+
|
|
1698
|
+
<div>
|
|
1699
|
+
<label class="mb-2 block text-sm font-medium">Feedback</label>
|
|
1700
|
+
<textarea
|
|
1701
|
+
class="min-h-40 w-full resize-y rounded-md border bg-background px-3 py-2 text-sm outline-none transition focus:ring-2 focus:ring-ring disabled:opacity-60"
|
|
1702
|
+
placeholder="Beschrijf wat er anders moet, wat je verwachtte of wat niet klopt."
|
|
1703
|
+
value={prompt()}
|
|
1704
|
+
disabled={mode() === "submitting"}
|
|
1705
|
+
onInput={(event) => setPrompt(event.currentTarget.value)}
|
|
1706
|
+
/>
|
|
1707
|
+
</div>
|
|
1708
|
+
|
|
1709
|
+
<div>
|
|
1710
|
+
<label class="mb-2 block text-sm font-medium">Bijlagen</label>
|
|
1711
|
+
<label class="flex cursor-pointer items-center gap-2 rounded-md border bg-background px-3 py-2 text-sm text-foreground transition hover:bg-muted">
|
|
1712
|
+
<FileUp class="h-4 w-4" />
|
|
1713
|
+
Voeg bestanden toe
|
|
1714
|
+
<input
|
|
1715
|
+
type="file"
|
|
1716
|
+
class="sr-only"
|
|
1717
|
+
multiple
|
|
1718
|
+
disabled={mode() === "submitting"}
|
|
1719
|
+
onChange={(event) => {
|
|
1720
|
+
setAttachments(Array.from(event.currentTarget.files ?? []));
|
|
1721
|
+
}}
|
|
1722
|
+
/>
|
|
1723
|
+
</label>
|
|
1724
|
+
<Show when={attachments().length > 0}>
|
|
1725
|
+
<div class="mt-2 space-y-1 text-xs text-muted-foreground">
|
|
1726
|
+
{attachments().map((file) => (
|
|
1727
|
+
<div class="truncate rounded border bg-muted/30 px-2 py-1">
|
|
1728
|
+
{file.name} ({formatBytes(file.size)})
|
|
1729
|
+
</div>
|
|
1730
|
+
))}
|
|
1731
|
+
</div>
|
|
1732
|
+
</Show>
|
|
1733
|
+
</div>
|
|
1734
|
+
|
|
1735
|
+
<div>
|
|
1736
|
+
<label class="mb-2 block text-sm font-medium">Video-opname</label>
|
|
1737
|
+
<div class="space-y-2 rounded-md border bg-background p-3">
|
|
1738
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
1739
|
+
<Show
|
|
1740
|
+
when={recordingState() === "recording"}
|
|
1741
|
+
fallback={
|
|
1742
|
+
<button
|
|
1743
|
+
type="button"
|
|
1744
|
+
class="inline-flex h-9 items-center gap-2 rounded-md border px-3 text-sm font-medium transition hover:bg-muted disabled:opacity-60"
|
|
1745
|
+
disabled={mode() === "submitting" || !supportsScreenRecording()}
|
|
1746
|
+
onClick={startRecording}
|
|
1747
|
+
>
|
|
1748
|
+
<Video class="h-4 w-4" />
|
|
1749
|
+
Neem video op
|
|
1750
|
+
</button>
|
|
1751
|
+
}
|
|
1752
|
+
>
|
|
1753
|
+
<button
|
|
1754
|
+
type="button"
|
|
1755
|
+
class="inline-flex h-9 items-center gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 text-sm font-medium transition hover:bg-destructive/15"
|
|
1756
|
+
disabled={mode() === "submitting"}
|
|
1757
|
+
onClick={stopRecording}
|
|
1758
|
+
>
|
|
1759
|
+
<StopCircle class="h-4 w-4" />
|
|
1760
|
+
Stop opname
|
|
1761
|
+
</button>
|
|
1762
|
+
</Show>
|
|
1763
|
+
|
|
1764
|
+
<Show when={recordingBlob()}>
|
|
1765
|
+
{(blob) => (
|
|
1766
|
+
<>
|
|
1767
|
+
<a
|
|
1768
|
+
class="inline-flex h-9 max-w-full items-center gap-2 truncate rounded-md border px-3 text-sm underline-offset-4 hover:bg-muted hover:underline"
|
|
1769
|
+
href={recordingUrl() ?? undefined}
|
|
1770
|
+
target="_blank"
|
|
1771
|
+
rel="noreferrer"
|
|
1772
|
+
>
|
|
1773
|
+
<Video class="h-4 w-4 shrink-0" />
|
|
1774
|
+
<span class="truncate">
|
|
1775
|
+
screen-recording.webm ({formatBytes(blob().size)},{" "}
|
|
1776
|
+
{formatDuration(recordingDurationMs())})
|
|
1777
|
+
</span>
|
|
1778
|
+
</a>
|
|
1779
|
+
<button
|
|
1780
|
+
type="button"
|
|
1781
|
+
class="inline-flex h-9 items-center gap-2 rounded-md border px-3 text-sm font-medium transition hover:bg-muted"
|
|
1782
|
+
disabled={mode() === "submitting"}
|
|
1783
|
+
onClick={clearRecording}
|
|
1784
|
+
>
|
|
1785
|
+
<Trash2 class="h-4 w-4" />
|
|
1786
|
+
Wissen
|
|
1787
|
+
</button>
|
|
1788
|
+
</>
|
|
1789
|
+
)}
|
|
1790
|
+
</Show>
|
|
1791
|
+
</div>
|
|
1792
|
+
<Show when={!supportsScreenRecording()}>
|
|
1793
|
+
<div class="text-xs text-muted-foreground">
|
|
1794
|
+
Video-opname is niet beschikbaar in deze browser.
|
|
1795
|
+
</div>
|
|
1796
|
+
</Show>
|
|
1797
|
+
</div>
|
|
1798
|
+
</div>
|
|
1799
|
+
|
|
1800
|
+
<Accordion title="Broncontext" defaultOpen>
|
|
1801
|
+
<div class="space-y-1">
|
|
1802
|
+
<div>Component: {screenshot().source?.componentName ?? "niet gevonden"}</div>
|
|
1803
|
+
<div>
|
|
1804
|
+
Bestand:{" "}
|
|
1805
|
+
{screenshot().source
|
|
1806
|
+
? `${screenshot().source?.filePath}:${screenshot().source?.line}`
|
|
1807
|
+
: "niet gevonden"}
|
|
1808
|
+
</div>
|
|
1809
|
+
<div>Element: {screenshot().source?.elementName ?? "onbekend"}</div>
|
|
1810
|
+
<div>Capture: {screenshot().captureMode === "page" ? "volledige pagina" : "element"}</div>
|
|
1811
|
+
</div>
|
|
1812
|
+
</Accordion>
|
|
1813
|
+
|
|
1814
|
+
<Accordion title="Console">
|
|
1815
|
+
<ConsolePreview preview={contextPreview()} />
|
|
1816
|
+
</Accordion>
|
|
1817
|
+
|
|
1818
|
+
<Accordion title="Scoped state">
|
|
1819
|
+
<ScopedStatePreview preview={contextPreview()} />
|
|
1820
|
+
</Accordion>
|
|
1821
|
+
|
|
1822
|
+
<Accordion title="Context">
|
|
1823
|
+
<ContextPreview
|
|
1824
|
+
attachments={attachments()}
|
|
1825
|
+
captureMode={screenshot().captureMode}
|
|
1826
|
+
preview={contextPreview()}
|
|
1827
|
+
/>
|
|
1828
|
+
</Accordion>
|
|
1829
|
+
|
|
1830
|
+
<Show when={error()}>
|
|
1831
|
+
<div class="rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-foreground">
|
|
1832
|
+
{error()}
|
|
1833
|
+
</div>
|
|
1834
|
+
</Show>
|
|
1835
|
+
</div>
|
|
1836
|
+
|
|
1837
|
+
<div class="mt-auto space-y-2 border-t p-4">
|
|
1838
|
+
<button
|
|
1839
|
+
type="button"
|
|
1840
|
+
class="inline-flex h-10 w-full items-center justify-center gap-2 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-60"
|
|
1841
|
+
disabled={mode() === "submitting" || !prompt().trim()}
|
|
1842
|
+
onClick={submitFeedback}
|
|
1843
|
+
>
|
|
1844
|
+
<Show
|
|
1845
|
+
when={submitTarget() === "linear"}
|
|
1846
|
+
fallback={
|
|
1847
|
+
<>
|
|
1848
|
+
<Send class="h-4 w-4" />
|
|
1849
|
+
Verstuur naar Linear
|
|
1850
|
+
</>
|
|
1851
|
+
}
|
|
1852
|
+
>
|
|
1853
|
+
<LoaderCircle class="h-4 w-4 animate-spin" />
|
|
1854
|
+
Versturen...
|
|
1855
|
+
</Show>
|
|
1856
|
+
</button>
|
|
1857
|
+
<button
|
|
1858
|
+
type="button"
|
|
1859
|
+
class="inline-flex h-10 w-full items-center justify-center gap-2 rounded-md border bg-background px-4 text-sm font-medium text-foreground transition hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-60"
|
|
1860
|
+
disabled={
|
|
1861
|
+
mode() === "submitting" ||
|
|
1862
|
+
!prompt().trim() ||
|
|
1863
|
+
!props.locator.config.startAgentSession
|
|
1864
|
+
}
|
|
1865
|
+
onClick={startAgentSession}
|
|
1866
|
+
title={
|
|
1867
|
+
props.locator.config.startAgentSession
|
|
1868
|
+
? "Start agent met deze feedback"
|
|
1869
|
+
: "Agent sessies zijn nog niet gekoppeld"
|
|
1870
|
+
}
|
|
1871
|
+
>
|
|
1872
|
+
<Show
|
|
1873
|
+
when={submitTarget() === "agent"}
|
|
1874
|
+
fallback={
|
|
1875
|
+
<>
|
|
1876
|
+
<Bot class="h-4 w-4" />
|
|
1877
|
+
Start agent
|
|
1878
|
+
</>
|
|
1879
|
+
}
|
|
1880
|
+
>
|
|
1881
|
+
<LoaderCircle class="h-4 w-4 animate-spin" />
|
|
1882
|
+
Agent starten...
|
|
1883
|
+
</Show>
|
|
1884
|
+
</button>
|
|
1885
|
+
<div class="grid grid-cols-2 gap-2">
|
|
1886
|
+
<button
|
|
1887
|
+
type="button"
|
|
1888
|
+
class="inline-flex h-10 w-full items-center justify-center gap-2 rounded-md border bg-background px-3 text-sm font-medium text-foreground transition hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-60"
|
|
1889
|
+
disabled={
|
|
1890
|
+
mode() === "submitting" ||
|
|
1891
|
+
!prompt().trim() ||
|
|
1892
|
+
!props.locator.config.startCodexThread
|
|
1893
|
+
}
|
|
1894
|
+
onClick={startCodexThread}
|
|
1895
|
+
title={
|
|
1896
|
+
props.locator.config.startCodexThread
|
|
1897
|
+
? "Start Codex thread in plan mode"
|
|
1898
|
+
: "Codex threads zijn nog niet gekoppeld"
|
|
1899
|
+
}
|
|
1900
|
+
>
|
|
1901
|
+
<Show
|
|
1902
|
+
when={submitTarget() === "codex"}
|
|
1903
|
+
fallback={
|
|
1904
|
+
<>
|
|
1905
|
+
<Bot class="h-4 w-4" />
|
|
1906
|
+
Codex
|
|
1907
|
+
</>
|
|
1908
|
+
}
|
|
1909
|
+
>
|
|
1910
|
+
<LoaderCircle class="h-4 w-4 animate-spin" />
|
|
1911
|
+
Codex...
|
|
1912
|
+
</Show>
|
|
1913
|
+
</button>
|
|
1914
|
+
<button
|
|
1915
|
+
type="button"
|
|
1916
|
+
class="inline-flex h-10 w-full items-center justify-center gap-2 rounded-md border bg-background px-3 text-sm font-medium text-foreground transition hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-60"
|
|
1917
|
+
disabled={
|
|
1918
|
+
mode() === "submitting" ||
|
|
1919
|
+
!prompt().trim() ||
|
|
1920
|
+
!props.locator.config.startOpenCodeThread
|
|
1921
|
+
}
|
|
1922
|
+
onClick={startOpenCodeThread}
|
|
1923
|
+
title={
|
|
1924
|
+
props.locator.config.startOpenCodeThread
|
|
1925
|
+
? "Start OpenCode sessie in plan mode"
|
|
1926
|
+
: "OpenCode sessies zijn nog niet gekoppeld"
|
|
1927
|
+
}
|
|
1928
|
+
>
|
|
1929
|
+
<Show
|
|
1930
|
+
when={submitTarget() === "opencode"}
|
|
1931
|
+
fallback={
|
|
1932
|
+
<>
|
|
1933
|
+
<Bot class="h-4 w-4" />
|
|
1934
|
+
OpenCode
|
|
1935
|
+
</>
|
|
1936
|
+
}
|
|
1937
|
+
>
|
|
1938
|
+
<LoaderCircle class="h-4 w-4 animate-spin" />
|
|
1939
|
+
OpenCode...
|
|
1940
|
+
</Show>
|
|
1941
|
+
</button>
|
|
1942
|
+
</div>
|
|
1943
|
+
</div>
|
|
1944
|
+
</aside>
|
|
1945
|
+
</div>
|
|
1946
|
+
</div>
|
|
1947
|
+
</div>
|
|
1948
|
+
)}
|
|
1949
|
+
</Show>
|
|
1950
|
+
</div>
|
|
1951
|
+
);
|
|
1952
|
+
};
|
|
1953
|
+
|
|
1954
|
+
function ToolButton(props: {
|
|
1955
|
+
active: boolean;
|
|
1956
|
+
icon: Component<JSX.IntrinsicElements["svg"]>;
|
|
1957
|
+
label: string;
|
|
1958
|
+
onClick: () => void;
|
|
1959
|
+
}) {
|
|
1960
|
+
return (
|
|
1961
|
+
<button
|
|
1962
|
+
type="button"
|
|
1963
|
+
class={`inline-flex h-9 items-center gap-2 rounded-md border px-3 text-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
|
|
1964
|
+
props.active
|
|
1965
|
+
? "border-primary bg-primary text-primary-foreground"
|
|
1966
|
+
: "bg-background text-foreground hover:bg-muted"
|
|
1967
|
+
}`}
|
|
1968
|
+
onClick={props.onClick}
|
|
1969
|
+
title={props.label}
|
|
1970
|
+
>
|
|
1971
|
+
<props.icon class="h-4 w-4" />
|
|
1972
|
+
{props.label}
|
|
1973
|
+
</button>
|
|
1974
|
+
);
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
function IconButton(props: {
|
|
1978
|
+
disabled?: boolean;
|
|
1979
|
+
icon: Component<JSX.IntrinsicElements["svg"]>;
|
|
1980
|
+
label: string;
|
|
1981
|
+
onClick: () => void;
|
|
1982
|
+
}) {
|
|
1983
|
+
return (
|
|
1984
|
+
<button
|
|
1985
|
+
type="button"
|
|
1986
|
+
class="inline-flex h-9 w-9 items-center justify-center rounded-md border bg-background text-foreground transition hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40"
|
|
1987
|
+
disabled={props.disabled}
|
|
1988
|
+
onClick={props.onClick}
|
|
1989
|
+
title={props.label}
|
|
1990
|
+
>
|
|
1991
|
+
<props.icon class="h-4 w-4" />
|
|
1992
|
+
<span class="sr-only">{props.label}</span>
|
|
1993
|
+
</button>
|
|
1994
|
+
);
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
function Accordion(props: {
|
|
1998
|
+
title: string;
|
|
1999
|
+
defaultOpen?: boolean;
|
|
2000
|
+
children: JSX.Element;
|
|
2001
|
+
}) {
|
|
2002
|
+
return (
|
|
2003
|
+
<details
|
|
2004
|
+
class="group rounded-md border bg-muted/40 text-xs text-muted-foreground"
|
|
2005
|
+
open={props.defaultOpen}
|
|
2006
|
+
>
|
|
2007
|
+
<summary class="flex cursor-pointer list-none items-center justify-between gap-2 px-3 py-2 font-medium text-foreground">
|
|
2008
|
+
{props.title}
|
|
2009
|
+
<ChevronDown class="h-4 w-4 transition group-open:rotate-180" />
|
|
2010
|
+
</summary>
|
|
2011
|
+
<div class="border-t px-3 py-2">{props.children}</div>
|
|
2012
|
+
</details>
|
|
2013
|
+
);
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
function ContextPreview(props: {
|
|
2017
|
+
attachments: File[];
|
|
2018
|
+
captureMode: ScreenshotState["captureMode"];
|
|
2019
|
+
preview: FeedbackLocatorContextPreview | null;
|
|
2020
|
+
}) {
|
|
2021
|
+
const context = () => ({
|
|
2022
|
+
url: window.location.href,
|
|
2023
|
+
route: `${window.location.pathname}${window.location.search}${window.location.hash}`,
|
|
2024
|
+
viewport: {
|
|
2025
|
+
width: window.innerWidth,
|
|
2026
|
+
height: window.innerHeight,
|
|
2027
|
+
devicePixelRatio: window.devicePixelRatio,
|
|
2028
|
+
},
|
|
2029
|
+
browser: {
|
|
2030
|
+
language: navigator.language,
|
|
2031
|
+
platform: navigator.platform,
|
|
2032
|
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
2033
|
+
},
|
|
2034
|
+
captureMode: props.captureMode,
|
|
2035
|
+
attachments: props.attachments.map((file) => ({
|
|
2036
|
+
name: file.name,
|
|
2037
|
+
type: file.type || "application/octet-stream",
|
|
2038
|
+
size: file.size,
|
|
2039
|
+
})),
|
|
2040
|
+
collectedContext: props.preview?.context ?? {},
|
|
2041
|
+
contextErrors: props.preview?.contextErrors ?? [],
|
|
2042
|
+
});
|
|
2043
|
+
|
|
2044
|
+
return (
|
|
2045
|
+
<pre class="max-h-72 overflow-auto whitespace-pre-wrap break-words rounded border bg-background/70 p-2 font-mono text-[11px] leading-relaxed">
|
|
2046
|
+
{JSON.stringify(context(), null, 2)}
|
|
2047
|
+
</pre>
|
|
2048
|
+
);
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
function ConsolePreview(props: { preview: FeedbackLocatorContextPreview | null }) {
|
|
2052
|
+
const logs = () =>
|
|
2053
|
+
((props.preview?.context.feedbackLocator as { consoleLogs?: unknown[] } | undefined)
|
|
2054
|
+
?.consoleLogs ?? []) as Array<{
|
|
2055
|
+
type?: string;
|
|
2056
|
+
message?: string;
|
|
2057
|
+
createdAt?: string;
|
|
2058
|
+
}>;
|
|
2059
|
+
|
|
2060
|
+
return (
|
|
2061
|
+
<Show when={logs().length > 0} fallback={<div>Geen console logs vastgelegd.</div>}>
|
|
2062
|
+
<div class="space-y-2">
|
|
2063
|
+
{logs()
|
|
2064
|
+
.slice(-12)
|
|
2065
|
+
.map((log) => (
|
|
2066
|
+
<div class="rounded border bg-background/70 p-2">
|
|
2067
|
+
<div class="font-medium text-foreground">{log.type ?? "console"}</div>
|
|
2068
|
+
<div class="mt-1 break-words">{log.message ?? ""}</div>
|
|
2069
|
+
</div>
|
|
2070
|
+
))}
|
|
2071
|
+
</div>
|
|
2072
|
+
</Show>
|
|
2073
|
+
);
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
function ScopedStatePreview(props: { preview: FeedbackLocatorContextPreview | null }) {
|
|
2077
|
+
const scopedState = () => props.preview?.context.scopedState;
|
|
2078
|
+
|
|
2079
|
+
return <JsonPreview value={scopedState() ?? "Geen scoped state voor dit element."} />;
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
function JsonPreview(props: { value: unknown }) {
|
|
2083
|
+
return (
|
|
2084
|
+
<pre class="max-h-72 overflow-auto whitespace-pre-wrap break-words rounded border bg-background/70 p-2 font-mono text-[11px] leading-relaxed">
|
|
2085
|
+
{typeof props.value === "string" ? props.value : JSON.stringify(props.value, null, 2)}
|
|
2086
|
+
</pre>
|
|
2087
|
+
);
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
function findSelectableElement(target: EventTarget | null) {
|
|
2091
|
+
if (!(target instanceof HTMLElement)) {
|
|
2092
|
+
return null;
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
if (target.closest("[data-feedback-locator-ui='true']")) {
|
|
2096
|
+
return null;
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
return (target.closest("[data-feedback-locator-id]") as HTMLElement | null) ?? target;
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
function formatBytes(size: number) {
|
|
2103
|
+
if (size < 1024) {
|
|
2104
|
+
return `${size} B`;
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
if (size < 1024 * 1024) {
|
|
2108
|
+
return `${Math.round(size / 1024)} KB`;
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
function openPendingAgentSessionWindow() {
|
|
2115
|
+
return openPendingExternalWindow(
|
|
2116
|
+
"Feedback agent starten...",
|
|
2117
|
+
"De context wordt voorbereid en de agent chat wordt geopend.",
|
|
2118
|
+
);
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
function openAgentSessionWindow(
|
|
2122
|
+
session: { sessionUrl?: string },
|
|
2123
|
+
pendingWindow?: Window | null,
|
|
2124
|
+
) {
|
|
2125
|
+
if (typeof window === "undefined" || !session.sessionUrl) {
|
|
2126
|
+
return;
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
if (pendingWindow && !pendingWindow.closed) {
|
|
2130
|
+
pendingWindow.location.href = session.sessionUrl;
|
|
2131
|
+
return;
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
window.open(session.sessionUrl, "_blank", "noopener,noreferrer");
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
function openPendingExternalWindow(title: string, message: string) {
|
|
2138
|
+
if (typeof window === "undefined") {
|
|
2139
|
+
return null;
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
const pendingWindow = window.open("about:blank", "_blank");
|
|
2143
|
+
|
|
2144
|
+
if (!pendingWindow) {
|
|
2145
|
+
return null;
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
try {
|
|
2149
|
+
pendingWindow.opener = null;
|
|
2150
|
+
pendingWindow.document.title = title;
|
|
2151
|
+
pendingWindow.document.body.innerHTML = `<main style="font-family: system-ui, sans-serif; padding: 24px;"><h1 style="font-size: 18px;">${escapeHtml(title)}</h1><p>${escapeHtml(message)}</p></main>`;
|
|
2152
|
+
} catch {
|
|
2153
|
+
// Cross-browser popup handling can deny document writes; navigation still works.
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
return pendingWindow;
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
function openExternalWindow(url: string | undefined, pendingWindow?: Window | null) {
|
|
2160
|
+
if (typeof window === "undefined" || !url) {
|
|
2161
|
+
return;
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
if (pendingWindow && !pendingWindow.closed) {
|
|
2165
|
+
pendingWindow.location.href = url;
|
|
2166
|
+
return;
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
window.open(url, "_blank", "noopener,noreferrer");
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
function closePendingWindow(pendingWindow?: Window | null) {
|
|
2173
|
+
try {
|
|
2174
|
+
if (pendingWindow && !pendingWindow.closed) {
|
|
2175
|
+
pendingWindow.close();
|
|
2176
|
+
}
|
|
2177
|
+
} catch {
|
|
2178
|
+
// Ignore popup cleanup errors.
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
function escapeHtml(value: string) {
|
|
2183
|
+
return value
|
|
2184
|
+
.replace(/&/g, "&")
|
|
2185
|
+
.replace(/</g, "<")
|
|
2186
|
+
.replace(/>/g, ">")
|
|
2187
|
+
.replace(/"/g, """)
|
|
2188
|
+
.replace(/'/g, "'");
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
function formatDuration(durationMs: number | null) {
|
|
2192
|
+
if (!durationMs) {
|
|
2193
|
+
return "0s";
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
return `${Math.max(1, Math.round(durationMs / 1000))}s`;
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
function supportsScreenRecording() {
|
|
2200
|
+
if (typeof navigator === "undefined" || typeof MediaRecorder === "undefined") {
|
|
2201
|
+
return false;
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
const mediaDevices = navigator.mediaDevices as
|
|
2205
|
+
| (MediaDevices & {
|
|
2206
|
+
getDisplayMedia?: MediaDevices["getDisplayMedia"];
|
|
2207
|
+
})
|
|
2208
|
+
| undefined;
|
|
2209
|
+
|
|
2210
|
+
return typeof mediaDevices?.getDisplayMedia === "function";
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
function getSupportedRecordingMimeType() {
|
|
2214
|
+
if (typeof MediaRecorder === "undefined") {
|
|
2215
|
+
return "";
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
const candidates = ["video/webm;codecs=vp9", "video/webm;codecs=vp8", "video/webm"];
|
|
2219
|
+
return candidates.find((mimeType) => MediaRecorder.isTypeSupported(mimeType)) ?? "";
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
function rectToBounds(rect: DOMRect): FeedbackLocatorBounds {
|
|
2223
|
+
return {
|
|
2224
|
+
x: rect.x,
|
|
2225
|
+
y: rect.y,
|
|
2226
|
+
width: rect.width,
|
|
2227
|
+
height: rect.height,
|
|
2228
|
+
top: rect.top,
|
|
2229
|
+
right: rect.right,
|
|
2230
|
+
bottom: rect.bottom,
|
|
2231
|
+
left: rect.left,
|
|
2232
|
+
};
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
function matchesHotkey(event: KeyboardEvent, hotkey: FeedbackLocatorHotkey) {
|
|
2236
|
+
return (
|
|
2237
|
+
event.key.toLowerCase() === hotkey.key.toLowerCase() &&
|
|
2238
|
+
Boolean(event.ctrlKey) === Boolean(hotkey.ctrl) &&
|
|
2239
|
+
Boolean(event.shiftKey) === Boolean(hotkey.shift) &&
|
|
2240
|
+
Boolean(event.altKey) === Boolean(hotkey.alt) &&
|
|
2241
|
+
Boolean(event.metaKey) === Boolean(hotkey.meta)
|
|
2242
|
+
);
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
function isEditableTarget(target: EventTarget | null) {
|
|
2246
|
+
if (!(target instanceof HTMLElement)) {
|
|
2247
|
+
return false;
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
const tagName = target.tagName.toLowerCase();
|
|
2251
|
+
return (
|
|
2252
|
+
tagName === "input" ||
|
|
2253
|
+
tagName === "textarea" ||
|
|
2254
|
+
tagName === "select" ||
|
|
2255
|
+
target.isContentEditable
|
|
2256
|
+
);
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
function getCanvasPoint(event: PointerEvent, canvas: HTMLCanvasElement) {
|
|
2260
|
+
const rect = canvas.getBoundingClientRect();
|
|
2261
|
+
return {
|
|
2262
|
+
x: ((event.clientX - rect.left) / rect.width) * canvas.width,
|
|
2263
|
+
y: ((event.clientY - rect.top) / rect.height) * canvas.height,
|
|
2264
|
+
};
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
function isTinyAnnotation(annotation: Annotation) {
|
|
2268
|
+
if (annotation.type === "pen") {
|
|
2269
|
+
return annotation.points.length < 2;
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
if (annotation.type === "text") {
|
|
2273
|
+
return annotation.text.trim().length === 0;
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
return (
|
|
2277
|
+
Math.abs(annotation.start.x - annotation.end.x) < 4 &&
|
|
2278
|
+
Math.abs(annotation.start.y - annotation.end.y) < 4
|
|
2279
|
+
);
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
function findAnnotationAtPoint(annotations: Annotation[], point: FeedbackLocatorPoint) {
|
|
2283
|
+
for (let index = annotations.length - 1; index >= 0; index -= 1) {
|
|
2284
|
+
if (isPointInAnnotation(annotations[index]!, point)) {
|
|
2285
|
+
return index;
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
return null;
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
function isPointInAnnotation(annotation: Annotation, point: FeedbackLocatorPoint) {
|
|
2293
|
+
if (annotation.type === "text") {
|
|
2294
|
+
return isPointInBounds(point, getAnnotationBounds(annotation, hitTolerance));
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
if (annotation.type === "rect") {
|
|
2298
|
+
return isPointNearRect(point, annotation, hitTolerance);
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
if (annotation.type === "arrow") {
|
|
2302
|
+
return (
|
|
2303
|
+
distanceToSegment(point, annotation.start, annotation.end) <= hitTolerance ||
|
|
2304
|
+
isPointInBounds(point, getAnnotationBounds(annotation, hitTolerance))
|
|
2305
|
+
);
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
for (let index = 1; index < annotation.points.length; index += 1) {
|
|
2309
|
+
if (
|
|
2310
|
+
distanceToSegment(point, annotation.points[index - 1]!, annotation.points[index]!) <=
|
|
2311
|
+
hitTolerance
|
|
2312
|
+
) {
|
|
2313
|
+
return true;
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
return false;
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
function translateAnnotation(annotation: Annotation, delta: FeedbackLocatorPoint): Annotation {
|
|
2321
|
+
if (annotation.type === "pen") {
|
|
2322
|
+
return {
|
|
2323
|
+
...annotation,
|
|
2324
|
+
points: annotation.points.map((point) => addPoint(point, delta)),
|
|
2325
|
+
};
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
if (annotation.type === "text") {
|
|
2329
|
+
return {
|
|
2330
|
+
...annotation,
|
|
2331
|
+
point: addPoint(annotation.point, delta),
|
|
2332
|
+
};
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
return {
|
|
2336
|
+
...annotation,
|
|
2337
|
+
start: addPoint(annotation.start, delta),
|
|
2338
|
+
end: addPoint(annotation.end, delta),
|
|
2339
|
+
};
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
function resizeAnnotation(
|
|
2343
|
+
annotation: Annotation,
|
|
2344
|
+
handle: AnnotationResizeHandle,
|
|
2345
|
+
point: FeedbackLocatorPoint,
|
|
2346
|
+
): Annotation {
|
|
2347
|
+
if (annotation.type === "arrow") {
|
|
2348
|
+
if (handle === "start") {
|
|
2349
|
+
return { ...annotation, start: point };
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
if (handle === "end") {
|
|
2353
|
+
return { ...annotation, end: point };
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
if (annotation.type === "text") {
|
|
2358
|
+
const bounds = getAnnotationBounds(annotation);
|
|
2359
|
+
const nextBounds = resizeBounds(bounds, handle, point);
|
|
2360
|
+
const nextFontSize = Math.max(12, Math.round(nextBounds.height / 1.35));
|
|
2361
|
+
return {
|
|
2362
|
+
...annotation,
|
|
2363
|
+
point: {
|
|
2364
|
+
x: nextBounds.left,
|
|
2365
|
+
y: nextBounds.top + nextFontSize,
|
|
2366
|
+
},
|
|
2367
|
+
fontSize: nextFontSize,
|
|
2368
|
+
};
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
if (annotation.type === "rect") {
|
|
2372
|
+
const bounds = resizeBounds(getAnnotationBounds(annotation), handle, point);
|
|
2373
|
+
return {
|
|
2374
|
+
...annotation,
|
|
2375
|
+
start: { x: bounds.left, y: bounds.top },
|
|
2376
|
+
end: { x: bounds.right, y: bounds.bottom },
|
|
2377
|
+
};
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
if (annotation.type === "pen") {
|
|
2381
|
+
const currentBounds = getAnnotationBounds(annotation);
|
|
2382
|
+
const nextBounds = resizeBounds(currentBounds, handle, point);
|
|
2383
|
+
return {
|
|
2384
|
+
...annotation,
|
|
2385
|
+
points: annotation.points.map((annotationPoint) =>
|
|
2386
|
+
transformPointBetweenBounds(annotationPoint, currentBounds, nextBounds),
|
|
2387
|
+
),
|
|
2388
|
+
};
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
return annotation;
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
function addPoint(point: FeedbackLocatorPoint, delta: FeedbackLocatorPoint) {
|
|
2395
|
+
return {
|
|
2396
|
+
x: point.x + delta.x,
|
|
2397
|
+
y: point.y + delta.y,
|
|
2398
|
+
};
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
function drawSelection(context: CanvasRenderingContext2D, annotation: Annotation) {
|
|
2402
|
+
const bounds = getAnnotationBounds(annotation, 8);
|
|
2403
|
+
|
|
2404
|
+
context.save();
|
|
2405
|
+
context.strokeStyle = selectionColor;
|
|
2406
|
+
context.lineWidth = 2;
|
|
2407
|
+
context.setLineDash([8, 5]);
|
|
2408
|
+
context.strokeRect(bounds.left, bounds.top, bounds.width, bounds.height);
|
|
2409
|
+
context.setLineDash([]);
|
|
2410
|
+
for (const handle of getResizeHandles(annotation)) {
|
|
2411
|
+
context.fillStyle = selectionColor;
|
|
2412
|
+
context.strokeStyle = "rgba(15, 23, 42, 0.85)";
|
|
2413
|
+
context.lineWidth = 2;
|
|
2414
|
+
context.fillRect(
|
|
2415
|
+
handle.point.x - resizeHandleSize / 2,
|
|
2416
|
+
handle.point.y - resizeHandleSize / 2,
|
|
2417
|
+
resizeHandleSize,
|
|
2418
|
+
resizeHandleSize,
|
|
2419
|
+
);
|
|
2420
|
+
context.strokeRect(
|
|
2421
|
+
handle.point.x - resizeHandleSize / 2,
|
|
2422
|
+
handle.point.y - resizeHandleSize / 2,
|
|
2423
|
+
resizeHandleSize,
|
|
2424
|
+
resizeHandleSize,
|
|
2425
|
+
);
|
|
2426
|
+
}
|
|
2427
|
+
context.restore();
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
function findResizeHandleAtPoint(annotation: Annotation, point: FeedbackLocatorPoint) {
|
|
2431
|
+
const handles = getResizeHandles(annotation);
|
|
2432
|
+
return (
|
|
2433
|
+
handles.find(
|
|
2434
|
+
(handle) =>
|
|
2435
|
+
Math.abs(point.x - handle.point.x) <= resizeHandleSize &&
|
|
2436
|
+
Math.abs(point.y - handle.point.y) <= resizeHandleSize,
|
|
2437
|
+
)?.handle ?? null
|
|
2438
|
+
);
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
function getResizeHandles(annotation: Annotation) {
|
|
2442
|
+
if (annotation.type === "arrow") {
|
|
2443
|
+
return [
|
|
2444
|
+
{ handle: "start" as const, point: annotation.start },
|
|
2445
|
+
{ handle: "end" as const, point: annotation.end },
|
|
2446
|
+
];
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
const bounds = getAnnotationBounds(annotation);
|
|
2450
|
+
return [
|
|
2451
|
+
{ handle: "nw" as const, point: { x: bounds.left, y: bounds.top } },
|
|
2452
|
+
{ handle: "ne" as const, point: { x: bounds.right, y: bounds.top } },
|
|
2453
|
+
{ handle: "sw" as const, point: { x: bounds.left, y: bounds.bottom } },
|
|
2454
|
+
{ handle: "se" as const, point: { x: bounds.right, y: bounds.bottom } },
|
|
2455
|
+
];
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
function resizeBounds(
|
|
2459
|
+
bounds: AnnotationBounds,
|
|
2460
|
+
handle: AnnotationResizeHandle,
|
|
2461
|
+
point: FeedbackLocatorPoint,
|
|
2462
|
+
) {
|
|
2463
|
+
let left = bounds.left;
|
|
2464
|
+
let top = bounds.top;
|
|
2465
|
+
let right = bounds.right;
|
|
2466
|
+
let bottom = bounds.bottom;
|
|
2467
|
+
|
|
2468
|
+
if (handle === "nw" || handle === "sw") {
|
|
2469
|
+
left = point.x;
|
|
2470
|
+
}
|
|
2471
|
+
if (handle === "ne" || handle === "se") {
|
|
2472
|
+
right = point.x;
|
|
2473
|
+
}
|
|
2474
|
+
if (handle === "nw" || handle === "ne") {
|
|
2475
|
+
top = point.y;
|
|
2476
|
+
}
|
|
2477
|
+
if (handle === "sw" || handle === "se") {
|
|
2478
|
+
bottom = point.y;
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
return boundsFromEdges(
|
|
2482
|
+
Math.min(left, right),
|
|
2483
|
+
Math.min(top, bottom),
|
|
2484
|
+
Math.max(left, right),
|
|
2485
|
+
Math.max(top, bottom),
|
|
2486
|
+
);
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
function transformPointBetweenBounds(
|
|
2490
|
+
point: FeedbackLocatorPoint,
|
|
2491
|
+
currentBounds: AnnotationBounds,
|
|
2492
|
+
nextBounds: AnnotationBounds,
|
|
2493
|
+
) {
|
|
2494
|
+
const xProgress =
|
|
2495
|
+
currentBounds.width === 0 ? 0 : (point.x - currentBounds.left) / currentBounds.width;
|
|
2496
|
+
const yProgress =
|
|
2497
|
+
currentBounds.height === 0 ? 0 : (point.y - currentBounds.top) / currentBounds.height;
|
|
2498
|
+
|
|
2499
|
+
return {
|
|
2500
|
+
x: nextBounds.left + xProgress * nextBounds.width,
|
|
2501
|
+
y: nextBounds.top + yProgress * nextBounds.height,
|
|
2502
|
+
};
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
function createTextAnnotation(
|
|
2506
|
+
draft: TextDraft,
|
|
2507
|
+
text: string,
|
|
2508
|
+
imageWidth = 900,
|
|
2509
|
+
color = annotationColor,
|
|
2510
|
+
): TextAnnotation {
|
|
2511
|
+
return {
|
|
2512
|
+
type: "text",
|
|
2513
|
+
point: draft.point,
|
|
2514
|
+
text,
|
|
2515
|
+
color,
|
|
2516
|
+
fontSize: Math.max(18, Math.round(imageWidth / 45)),
|
|
2517
|
+
};
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
function getAnnotationBounds(annotation: Annotation, padding = 0): AnnotationBounds {
|
|
2521
|
+
if (annotation.type === "text") {
|
|
2522
|
+
const estimatedWidth = Math.max(
|
|
2523
|
+
annotation.fontSize * 2,
|
|
2524
|
+
annotation.text.length * annotation.fontSize * 0.62,
|
|
2525
|
+
);
|
|
2526
|
+
const estimatedHeight = annotation.fontSize * 1.35;
|
|
2527
|
+
|
|
2528
|
+
return boundsFromEdges(
|
|
2529
|
+
annotation.point.x - padding,
|
|
2530
|
+
annotation.point.y - annotation.fontSize - padding,
|
|
2531
|
+
annotation.point.x + estimatedWidth + padding,
|
|
2532
|
+
annotation.point.y + estimatedHeight * 0.25 + padding,
|
|
2533
|
+
);
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
if (annotation.type === "pen") {
|
|
2537
|
+
const xs = annotation.points.map((point) => point.x);
|
|
2538
|
+
const ys = annotation.points.map((point) => point.y);
|
|
2539
|
+
|
|
2540
|
+
return boundsFromEdges(
|
|
2541
|
+
Math.min(...xs) - padding,
|
|
2542
|
+
Math.min(...ys) - padding,
|
|
2543
|
+
Math.max(...xs) + padding,
|
|
2544
|
+
Math.max(...ys) + padding,
|
|
2545
|
+
);
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
return boundsFromEdges(
|
|
2549
|
+
Math.min(annotation.start.x, annotation.end.x) - padding,
|
|
2550
|
+
Math.min(annotation.start.y, annotation.end.y) - padding,
|
|
2551
|
+
Math.max(annotation.start.x, annotation.end.x) + padding,
|
|
2552
|
+
Math.max(annotation.start.y, annotation.end.y) + padding,
|
|
2553
|
+
);
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
function boundsFromEdges(
|
|
2557
|
+
left: number,
|
|
2558
|
+
top: number,
|
|
2559
|
+
right: number,
|
|
2560
|
+
bottom: number,
|
|
2561
|
+
): AnnotationBounds {
|
|
2562
|
+
return {
|
|
2563
|
+
left,
|
|
2564
|
+
top,
|
|
2565
|
+
right,
|
|
2566
|
+
bottom,
|
|
2567
|
+
width: right - left,
|
|
2568
|
+
height: bottom - top,
|
|
2569
|
+
};
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
function isPointInBounds(point: FeedbackLocatorPoint, bounds: AnnotationBounds) {
|
|
2573
|
+
return (
|
|
2574
|
+
point.x >= bounds.left &&
|
|
2575
|
+
point.x <= bounds.right &&
|
|
2576
|
+
point.y >= bounds.top &&
|
|
2577
|
+
point.y <= bounds.bottom
|
|
2578
|
+
);
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
function isPointNearRect(
|
|
2582
|
+
point: FeedbackLocatorPoint,
|
|
2583
|
+
annotation: RectAnnotation,
|
|
2584
|
+
tolerance: number,
|
|
2585
|
+
) {
|
|
2586
|
+
const left = Math.min(annotation.start.x, annotation.end.x);
|
|
2587
|
+
const right = Math.max(annotation.start.x, annotation.end.x);
|
|
2588
|
+
const top = Math.min(annotation.start.y, annotation.end.y);
|
|
2589
|
+
const bottom = Math.max(annotation.start.y, annotation.end.y);
|
|
2590
|
+
|
|
2591
|
+
const withinHorizontal = point.x >= left - tolerance && point.x <= right + tolerance;
|
|
2592
|
+
const withinVertical = point.y >= top - tolerance && point.y <= bottom + tolerance;
|
|
2593
|
+
const nearHorizontalEdge =
|
|
2594
|
+
Math.abs(point.y - top) <= tolerance || Math.abs(point.y - bottom) <= tolerance;
|
|
2595
|
+
const nearVerticalEdge =
|
|
2596
|
+
Math.abs(point.x - left) <= tolerance || Math.abs(point.x - right) <= tolerance;
|
|
2597
|
+
|
|
2598
|
+
return (withinHorizontal && nearHorizontalEdge) || (withinVertical && nearVerticalEdge);
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
function distanceToSegment(
|
|
2602
|
+
point: FeedbackLocatorPoint,
|
|
2603
|
+
start: FeedbackLocatorPoint,
|
|
2604
|
+
end: FeedbackLocatorPoint,
|
|
2605
|
+
) {
|
|
2606
|
+
const lengthSquared =
|
|
2607
|
+
(end.x - start.x) * (end.x - start.x) + (end.y - start.y) * (end.y - start.y);
|
|
2608
|
+
|
|
2609
|
+
if (lengthSquared === 0) {
|
|
2610
|
+
return Math.hypot(point.x - start.x, point.y - start.y);
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
const progress = Math.max(
|
|
2614
|
+
0,
|
|
2615
|
+
Math.min(
|
|
2616
|
+
1,
|
|
2617
|
+
((point.x - start.x) * (end.x - start.x) + (point.y - start.y) * (end.y - start.y)) /
|
|
2618
|
+
lengthSquared,
|
|
2619
|
+
),
|
|
2620
|
+
);
|
|
2621
|
+
const closest = {
|
|
2622
|
+
x: start.x + progress * (end.x - start.x),
|
|
2623
|
+
y: start.y + progress * (end.y - start.y),
|
|
2624
|
+
};
|
|
2625
|
+
|
|
2626
|
+
return Math.hypot(point.x - closest.x, point.y - closest.y);
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2629
|
+
function drawAnnotation(context: CanvasRenderingContext2D, annotation: Annotation) {
|
|
2630
|
+
context.save();
|
|
2631
|
+
context.strokeStyle = annotation.color;
|
|
2632
|
+
context.fillStyle = annotation.color;
|
|
2633
|
+
context.lineWidth = "width" in annotation ? annotation.width : annotationWidth;
|
|
2634
|
+
context.lineCap = "round";
|
|
2635
|
+
context.lineJoin = "round";
|
|
2636
|
+
|
|
2637
|
+
if (annotation.type === "pen") {
|
|
2638
|
+
drawPen(context, annotation);
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
if (annotation.type === "rect") {
|
|
2642
|
+
context.strokeRect(
|
|
2643
|
+
annotation.start.x,
|
|
2644
|
+
annotation.start.y,
|
|
2645
|
+
annotation.end.x - annotation.start.x,
|
|
2646
|
+
annotation.end.y - annotation.start.y,
|
|
2647
|
+
);
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
if (annotation.type === "arrow") {
|
|
2651
|
+
drawArrow(context, annotation);
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
if (annotation.type === "text") {
|
|
2655
|
+
context.font = `600 ${annotation.fontSize}px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`;
|
|
2656
|
+
context.lineWidth = Math.max(3, annotation.fontSize / 8);
|
|
2657
|
+
context.strokeStyle = "rgba(0,0,0,0.65)";
|
|
2658
|
+
context.strokeText(annotation.text, annotation.point.x, annotation.point.y);
|
|
2659
|
+
context.fillStyle = annotation.color;
|
|
2660
|
+
context.fillText(annotation.text, annotation.point.x, annotation.point.y);
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
context.restore();
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2666
|
+
function drawPen(context: CanvasRenderingContext2D, annotation: PenAnnotation) {
|
|
2667
|
+
const [firstPoint, ...rest] = annotation.points;
|
|
2668
|
+
if (!firstPoint) {
|
|
2669
|
+
return;
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
context.beginPath();
|
|
2673
|
+
context.moveTo(firstPoint.x, firstPoint.y);
|
|
2674
|
+
for (const point of rest) {
|
|
2675
|
+
context.lineTo(point.x, point.y);
|
|
2676
|
+
}
|
|
2677
|
+
context.stroke();
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2680
|
+
function drawArrow(context: CanvasRenderingContext2D, annotation: ArrowAnnotation) {
|
|
2681
|
+
const angle = Math.atan2(
|
|
2682
|
+
annotation.end.y - annotation.start.y,
|
|
2683
|
+
annotation.end.x - annotation.start.x,
|
|
2684
|
+
);
|
|
2685
|
+
const headLength = 18;
|
|
2686
|
+
|
|
2687
|
+
context.beginPath();
|
|
2688
|
+
context.moveTo(annotation.start.x, annotation.start.y);
|
|
2689
|
+
context.lineTo(annotation.end.x, annotation.end.y);
|
|
2690
|
+
context.stroke();
|
|
2691
|
+
|
|
2692
|
+
context.beginPath();
|
|
2693
|
+
context.moveTo(annotation.end.x, annotation.end.y);
|
|
2694
|
+
context.lineTo(
|
|
2695
|
+
annotation.end.x - headLength * Math.cos(angle - Math.PI / 6),
|
|
2696
|
+
annotation.end.y - headLength * Math.sin(angle - Math.PI / 6),
|
|
2697
|
+
);
|
|
2698
|
+
context.lineTo(
|
|
2699
|
+
annotation.end.x - headLength * Math.cos(angle + Math.PI / 6),
|
|
2700
|
+
annotation.end.y - headLength * Math.sin(angle + Math.PI / 6),
|
|
2701
|
+
);
|
|
2702
|
+
context.closePath();
|
|
2703
|
+
context.fill();
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
function getImageSize(blob: Blob) {
|
|
2707
|
+
const url = URL.createObjectURL(blob);
|
|
2708
|
+
return loadImage(url)
|
|
2709
|
+
.then((image) => ({
|
|
2710
|
+
width: image.naturalWidth,
|
|
2711
|
+
height: image.naturalHeight,
|
|
2712
|
+
}))
|
|
2713
|
+
.finally(() => URL.revokeObjectURL(url));
|
|
2714
|
+
}
|
|
2715
|
+
|
|
2716
|
+
function loadImage(url: string) {
|
|
2717
|
+
return new Promise<HTMLImageElement>((resolve, reject) => {
|
|
2718
|
+
const image = new Image();
|
|
2719
|
+
image.onload = () => resolve(image);
|
|
2720
|
+
image.onerror = () => reject(new Error("Screenshot kon niet worden geladen."));
|
|
2721
|
+
image.src = url;
|
|
2722
|
+
});
|
|
2723
|
+
}
|