context_drop 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +21 -0
- data/README.md +337 -0
- data/Rakefile +20 -0
- data/SPEC.md +532 -0
- data/app/assets/javascripts/context_drop/context_drop.js +3508 -0
- data/app/assets/stylesheets/context_drop/application.css +2 -0
- data/app/controllers/context_drop/application_controller.rb +114 -0
- data/app/controllers/context_drop/attachments_controller.rb +31 -0
- data/app/controllers/context_drop/configs_controller.rb +20 -0
- data/app/controllers/context_drop/dashboard/feedbacks_controller.rb +107 -0
- data/app/controllers/context_drop/feedbacks_controller.rb +208 -0
- data/app/controllers/context_drop/health_controller.rb +9 -0
- data/app/controllers/context_drop/mcp_controller.rb +67 -0
- data/app/helpers/context_drop/widget_helper.rb +62 -0
- data/app/javascript/context_drop/__tests__/widget.test.ts +319 -0
- data/app/javascript/context_drop/api-client.ts +87 -0
- data/app/javascript/context_drop/element-picker.ts +1004 -0
- data/app/javascript/context_drop/icons.ts +35 -0
- data/app/javascript/context_drop/index.ts +11 -0
- data/app/javascript/context_drop/sanitizer.ts +454 -0
- data/app/javascript/context_drop/screenshot.ts +214 -0
- data/app/javascript/context_drop/styles.ts +690 -0
- data/app/javascript/context_drop/types.ts +110 -0
- data/app/javascript/context_drop/widget.ts +1120 -0
- data/app/models/context_drop/application_record.rb +7 -0
- data/app/models/context_drop/attachment.rb +30 -0
- data/app/models/context_drop/element_reference.rb +47 -0
- data/app/models/context_drop/feedback.rb +94 -0
- data/app/services/context_drop/metadata_sanitizer.rb +215 -0
- data/app/services/context_drop/upload_validator.rb +217 -0
- data/app/views/context_drop/dashboard/feedbacks/index.html.erb +86 -0
- data/app/views/context_drop/dashboard/feedbacks/show.html.erb +121 -0
- data/app/views/layouts/context_drop/application.html.erb +215 -0
- data/config/routes.rb +17 -0
- data/db/migrate/20261109000001_create_context_drop_feedbacks.rb +23 -0
- data/db/migrate/20261109000002_create_context_drop_element_references.rb +29 -0
- data/db/migrate/20261109000003_create_context_drop_attachments.rb +24 -0
- data/lib/context_drop/base64_util.rb +15 -0
- data/lib/context_drop/configuration.rb +155 -0
- data/lib/context_drop/engine.rb +32 -0
- data/lib/context_drop/mcp/dispatcher.rb +197 -0
- data/lib/context_drop/mcp/prompts/investigate_feedback.rb +83 -0
- data/lib/context_drop/mcp/server.rb +25 -0
- data/lib/context_drop/mcp/stdio_runner.rb +62 -0
- data/lib/context_drop/mcp/tools/get_feedback.rb +133 -0
- data/lib/context_drop/mcp/tools/get_feedback_attachment.rb +71 -0
- data/lib/context_drop/mcp/tools/list_feedbacks.rb +127 -0
- data/lib/context_drop/mcp/tools/update_feedback_status.rb +72 -0
- data/lib/context_drop/version.rb +5 -0
- data/lib/context_drop.rb +32 -0
- data/lib/generators/context_drop/install/install_generator.rb +52 -0
- data/lib/generators/context_drop/install/templates/initializer.rb.tt +55 -0
- data/lib/tasks/context_drop_tasks.rake +14 -0
- metadata +246 -0
|
@@ -0,0 +1,1120 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ElementCandidateInfo,
|
|
3
|
+
ElementReferenceSnapshot,
|
|
4
|
+
FeedbackSubmissionPayload,
|
|
5
|
+
PendingAttachmentItem,
|
|
6
|
+
SessionFeedbackEntry,
|
|
7
|
+
WidgetConfig,
|
|
8
|
+
WidgetPosition
|
|
9
|
+
} from "./types.ts";
|
|
10
|
+
import { computeSelector, MANDATORY_EXCLUDED_SELECTORS } from "./sanitizer.ts";
|
|
11
|
+
import {
|
|
12
|
+
ICON_CAMERA,
|
|
13
|
+
ICON_CHAT,
|
|
14
|
+
ICON_CHEVRON_DOWN,
|
|
15
|
+
ICON_CHEVRON_RIGHT,
|
|
16
|
+
ICON_CHEVRON_UP,
|
|
17
|
+
ICON_CLOSE,
|
|
18
|
+
ICON_EXPAND,
|
|
19
|
+
ICON_GRIP,
|
|
20
|
+
ICON_IMAGE,
|
|
21
|
+
ICON_MINIMIZE,
|
|
22
|
+
ICON_PIN,
|
|
23
|
+
ICON_SEND
|
|
24
|
+
} from "./icons.ts";
|
|
25
|
+
import { WIDGET_SHADOW_STYLES } from "./styles.ts";
|
|
26
|
+
import { ContextDropApiClient } from "./api-client.ts";
|
|
27
|
+
import { ElementPicker } from "./element-picker.ts";
|
|
28
|
+
import { captureElementScreenshot, captureViewportScreenshot } from "./screenshot.ts";
|
|
29
|
+
|
|
30
|
+
const DEFAULT_CONFIG: WidgetConfig = {
|
|
31
|
+
enabled: true,
|
|
32
|
+
position: "top_left",
|
|
33
|
+
placeholder: "Describe the issue or drop context from the page...",
|
|
34
|
+
features: {
|
|
35
|
+
element_selection: true,
|
|
36
|
+
screenshot_capture: true,
|
|
37
|
+
element_capture_padding: 16
|
|
38
|
+
},
|
|
39
|
+
limits: {
|
|
40
|
+
max_attachments: 10,
|
|
41
|
+
max_element_references: 10,
|
|
42
|
+
max_attachment_size: 10 * 1024 * 1024,
|
|
43
|
+
allowed_content_types: ["image/png", "image/jpeg", "image/webp", "image/gif"],
|
|
44
|
+
max_image_dimensions: [4096, 4096]
|
|
45
|
+
},
|
|
46
|
+
privacy: {
|
|
47
|
+
excluded_selectors: Array.from(MANDATORY_EXCLUDED_SELECTORS)
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const BaseHTMLElement: typeof HTMLElement =
|
|
52
|
+
typeof HTMLElement !== "undefined"
|
|
53
|
+
? HTMLElement
|
|
54
|
+
: (class {} as unknown as typeof HTMLElement);
|
|
55
|
+
|
|
56
|
+
export class ContextDropWidgetElement extends BaseHTMLElement {
|
|
57
|
+
public shadow: ShadowRoot | null = null;
|
|
58
|
+
private config: WidgetConfig = structuredClone(DEFAULT_CONFIG);
|
|
59
|
+
private customMetadata: Record<string, unknown> = {};
|
|
60
|
+
private apiClient: ContextDropApiClient = new ContextDropApiClient("/context_drop");
|
|
61
|
+
private picker: ElementPicker | null = null;
|
|
62
|
+
|
|
63
|
+
private isOpen: boolean = false;
|
|
64
|
+
private isMinimized: boolean = false;
|
|
65
|
+
private isSubmitting: boolean = false;
|
|
66
|
+
private freePos: { left: number; top: number } | null = null;
|
|
67
|
+
|
|
68
|
+
private elementReferences: ElementReferenceSnapshot[] = [];
|
|
69
|
+
private elementDomNodes: Map<string, Element> = new Map();
|
|
70
|
+
private attachments: PendingAttachmentItem[] = [];
|
|
71
|
+
private sessionHistory: SessionFeedbackEntry[] = [];
|
|
72
|
+
|
|
73
|
+
private rootContainer: HTMLDivElement | null = null;
|
|
74
|
+
private composerEl: HTMLDivElement | null = null;
|
|
75
|
+
private compactBarEl: HTMLDivElement | null = null;
|
|
76
|
+
private compactBreadcrumbEl: HTMLDivElement | null = null;
|
|
77
|
+
private compactPinBtn: HTMLButtonElement | null = null;
|
|
78
|
+
private pinDockEl: HTMLDivElement | null = null;
|
|
79
|
+
private floatingPinBtn: HTMLButtonElement | null = null;
|
|
80
|
+
private triggerBtn: HTMLButtonElement | null = null;
|
|
81
|
+
private cornerPinBtn: HTMLButtonElement | null = null;
|
|
82
|
+
private chatThreadEl: HTMLDivElement | null = null;
|
|
83
|
+
private textareaEl: HTMLTextAreaElement | null = null;
|
|
84
|
+
private trayEl: HTMLDivElement | null = null;
|
|
85
|
+
private statusEl: HTMLDivElement | null = null;
|
|
86
|
+
private fileInputEl: HTMLInputElement | null = null;
|
|
87
|
+
private submitBtn: HTMLButtonElement | null = null;
|
|
88
|
+
|
|
89
|
+
public connectedCallback(): void {
|
|
90
|
+
if (!this.shadow) {
|
|
91
|
+
this.shadow = this.attachShadow({ mode: "open" });
|
|
92
|
+
}
|
|
93
|
+
this.readDataAttributes();
|
|
94
|
+
this.renderShadowDom();
|
|
95
|
+
|
|
96
|
+
this.picker = new ElementPicker({
|
|
97
|
+
onSelect: (snapshot, el) => {
|
|
98
|
+
const added = this.addElementReference(snapshot, el);
|
|
99
|
+
if (added) {
|
|
100
|
+
this.showStatus(`Pinned ${snapshot.selector}`, "success");
|
|
101
|
+
}
|
|
102
|
+
this.syncUiStates();
|
|
103
|
+
},
|
|
104
|
+
onExit: () => {
|
|
105
|
+
this.isMinimized = false;
|
|
106
|
+
this.openComposer();
|
|
107
|
+
this.syncUiStates();
|
|
108
|
+
},
|
|
109
|
+
canSelectMore: () => this.elementReferences.length < this.config.limits.max_element_references,
|
|
110
|
+
getExcludedSelectors: () => this.config.privacy.excluded_selectors,
|
|
111
|
+
onStateChange: (state) => {
|
|
112
|
+
this.renderCompactBreadcrumbs(state.activeCandidate, state.ancestors);
|
|
113
|
+
this.syncPinActiveIndicators(state.active);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
public disconnectedCallback(): void {
|
|
119
|
+
if (this.picker && this.picker.isActive()) {
|
|
120
|
+
this.picker.stop();
|
|
121
|
+
}
|
|
122
|
+
this.picker?.hideExternalPreview();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
public getConfig(): WidgetConfig {
|
|
126
|
+
return this.config;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
public getElementReferences(): ElementReferenceSnapshot[] {
|
|
130
|
+
return [...this.elementReferences];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
public getAttachments(): PendingAttachmentItem[] {
|
|
134
|
+
return [...this.attachments];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
public getSessionHistory(): SessionFeedbackEntry[] {
|
|
138
|
+
return [...this.sessionHistory];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
public openComposer(): void {
|
|
142
|
+
this.isOpen = true;
|
|
143
|
+
this.isMinimized = false;
|
|
144
|
+
this.syncUiStates();
|
|
145
|
+
if (this.textareaEl) {
|
|
146
|
+
this.textareaEl.focus();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
public closeComposer(): void {
|
|
151
|
+
this.isOpen = false;
|
|
152
|
+
this.isMinimized = false;
|
|
153
|
+
if (this.picker && this.picker.isActive()) {
|
|
154
|
+
this.picker.stop();
|
|
155
|
+
}
|
|
156
|
+
this.picker?.hideExternalPreview();
|
|
157
|
+
this.syncUiStates();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
public toggleComposer(): void {
|
|
161
|
+
if (this.isOpen && !this.isMinimized) {
|
|
162
|
+
this.closeComposer();
|
|
163
|
+
} else {
|
|
164
|
+
this.openComposer();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
public minimizeToCompactBar(): void {
|
|
169
|
+
this.isOpen = true;
|
|
170
|
+
this.isMinimized = true;
|
|
171
|
+
this.syncUiStates();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
public expandFromCompactBar(): void {
|
|
175
|
+
if (this.picker && this.picker.isActive()) {
|
|
176
|
+
this.picker.stop();
|
|
177
|
+
}
|
|
178
|
+
this.isMinimized = false;
|
|
179
|
+
this.openComposer();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
public startPinSelection(): void {
|
|
183
|
+
if (!this.config.features.element_selection || !this.picker) return;
|
|
184
|
+
if (this.elementReferences.length >= this.config.limits.max_element_references) {
|
|
185
|
+
this.openComposer();
|
|
186
|
+
this.showStatus(
|
|
187
|
+
`Maximum of ${this.config.limits.max_element_references} pinned elements reached.`,
|
|
188
|
+
"error"
|
|
189
|
+
);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
this.minimizeToCompactBar();
|
|
193
|
+
this.picker.start();
|
|
194
|
+
this.syncUiStates();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
public togglePinSelection(): void {
|
|
198
|
+
if (this.picker && this.picker.isActive()) {
|
|
199
|
+
this.picker.stop();
|
|
200
|
+
} else {
|
|
201
|
+
this.startPinSelection();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
public setFreePosition(left: number, top: number): void {
|
|
206
|
+
if (!this.rootContainer) return;
|
|
207
|
+
const vw = typeof window !== "undefined" ? window.innerWidth || 1024 : 1024;
|
|
208
|
+
const vh = typeof window !== "undefined" ? window.innerHeight || 768 : 768;
|
|
209
|
+
const clampedLeft = Math.max(8, Math.min(left, vw - 72));
|
|
210
|
+
const clampedTop = Math.max(8, Math.min(top, vh - 48));
|
|
211
|
+
|
|
212
|
+
this.freePos = { left: clampedLeft, top: clampedTop };
|
|
213
|
+
this.rootContainer.classList.add("is-free-positioned");
|
|
214
|
+
this.rootContainer.style.left = `${clampedLeft}px`;
|
|
215
|
+
this.rootContainer.style.top = `${clampedTop}px`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
public addElementReference(snapshot: ElementReferenceSnapshot, elementNode?: Element): boolean {
|
|
219
|
+
if (this.elementReferences.length >= this.config.limits.max_element_references) {
|
|
220
|
+
this.showStatus(
|
|
221
|
+
`Maximum of ${this.config.limits.max_element_references} DOM elements reached.`,
|
|
222
|
+
"error"
|
|
223
|
+
);
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
this.elementReferences.push(snapshot);
|
|
227
|
+
if (elementNode) {
|
|
228
|
+
this.elementDomNodes.set(snapshot.client_ref, elementNode);
|
|
229
|
+
}
|
|
230
|
+
this.renderTray();
|
|
231
|
+
this.syncUiStates();
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
public removeElementReference(clientRef: string): void {
|
|
236
|
+
this.elementReferences = this.elementReferences.filter((el) => el.client_ref !== clientRef);
|
|
237
|
+
this.elementDomNodes.delete(clientRef);
|
|
238
|
+
this.picker?.hideExternalPreview();
|
|
239
|
+
this.renderTray();
|
|
240
|
+
this.syncUiStates();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
public removeAttachment(attachmentId: string): void {
|
|
244
|
+
this.attachments = this.attachments.filter((att) => att.id !== attachmentId);
|
|
245
|
+
this.renderTray();
|
|
246
|
+
this.syncUiStates();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private readDataAttributes(): void {
|
|
250
|
+
const endpoint = this.getAttribute("data-endpoint") || "/context_drop";
|
|
251
|
+
this.apiClient = new ContextDropApiClient(endpoint);
|
|
252
|
+
|
|
253
|
+
const rawConfig = this.getAttribute("data-config");
|
|
254
|
+
if (rawConfig) {
|
|
255
|
+
try {
|
|
256
|
+
const parsed = JSON.parse(rawConfig) as Partial<WidgetConfig>;
|
|
257
|
+
this.config = {
|
|
258
|
+
...this.config,
|
|
259
|
+
...parsed,
|
|
260
|
+
features: { ...this.config.features, ...(parsed.features || {}) },
|
|
261
|
+
limits: { ...this.config.limits, ...(parsed.limits || {}) },
|
|
262
|
+
privacy: { ...this.config.privacy, ...(parsed.privacy || {}) }
|
|
263
|
+
};
|
|
264
|
+
} catch {
|
|
265
|
+
// Ignore malformed JSON attribute
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const posOverride = this.getAttribute("data-position") as WidgetPosition | null;
|
|
270
|
+
if (
|
|
271
|
+
posOverride &&
|
|
272
|
+
["bottom_right", "bottom_left", "top_right", "top_left"].includes(posOverride)
|
|
273
|
+
) {
|
|
274
|
+
this.config.position = posOverride;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const placeholderOverride = this.getAttribute("data-placeholder");
|
|
278
|
+
if (placeholderOverride) {
|
|
279
|
+
this.config.placeholder = placeholderOverride;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const rawMetadata = this.getAttribute("data-metadata");
|
|
283
|
+
if (rawMetadata) {
|
|
284
|
+
try {
|
|
285
|
+
this.customMetadata = JSON.parse(rawMetadata) as Record<string, unknown>;
|
|
286
|
+
} catch {
|
|
287
|
+
this.customMetadata = {};
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private attachDragHandle(handleEl: HTMLElement, onClickFallback?: () => void): void {
|
|
293
|
+
let startX = 0;
|
|
294
|
+
let startY = 0;
|
|
295
|
+
let initialLeft = 0;
|
|
296
|
+
let initialTop = 0;
|
|
297
|
+
let dragging = false;
|
|
298
|
+
let moved = false;
|
|
299
|
+
|
|
300
|
+
const onPointerMove = (ev: PointerEvent): void => {
|
|
301
|
+
if (!dragging) return;
|
|
302
|
+
const dx = ev.clientX - startX;
|
|
303
|
+
const dy = ev.clientY - startY;
|
|
304
|
+
if (!moved && Math.hypot(dx, dy) > 5) {
|
|
305
|
+
moved = true;
|
|
306
|
+
this.pinDockEl?.classList.add("is-dragging");
|
|
307
|
+
}
|
|
308
|
+
if (moved) {
|
|
309
|
+
this.setFreePosition(initialLeft + dx, initialTop + dy);
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const onPointerUp = (): void => {
|
|
314
|
+
if (!dragging) return;
|
|
315
|
+
dragging = false;
|
|
316
|
+
this.pinDockEl?.classList.remove("is-dragging");
|
|
317
|
+
window.removeEventListener("pointermove", onPointerMove);
|
|
318
|
+
window.removeEventListener("pointerup", onPointerUp);
|
|
319
|
+
if (!moved && onClickFallback) {
|
|
320
|
+
onClickFallback();
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
handleEl.addEventListener("pointerdown", (ev: PointerEvent) => {
|
|
325
|
+
// Only primary mouse button or touch
|
|
326
|
+
if (ev.button !== 0) return;
|
|
327
|
+
const targetEl = ev.target instanceof Element ? ev.target : null;
|
|
328
|
+
if (
|
|
329
|
+
targetEl &&
|
|
330
|
+
handleEl !== this.pinDockEl &&
|
|
331
|
+
targetEl.closest("button")
|
|
332
|
+
) {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (!this.rootContainer) return;
|
|
337
|
+
const rect = this.rootContainer.getBoundingClientRect();
|
|
338
|
+
startX = ev.clientX;
|
|
339
|
+
startY = ev.clientY;
|
|
340
|
+
initialLeft = this.freePos ? this.freePos.left : rect.left;
|
|
341
|
+
initialTop = this.freePos ? this.freePos.top : rect.top;
|
|
342
|
+
dragging = true;
|
|
343
|
+
moved = false;
|
|
344
|
+
|
|
345
|
+
window.addEventListener("pointermove", onPointerMove);
|
|
346
|
+
window.addEventListener("pointerup", onPointerUp);
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private renderShadowDom(): void {
|
|
351
|
+
if (!this.shadow) return;
|
|
352
|
+
this.shadow.innerHTML = "";
|
|
353
|
+
|
|
354
|
+
const styleEl = document.createElement("style");
|
|
355
|
+
styleEl.textContent = WIDGET_SHADOW_STYLES;
|
|
356
|
+
this.shadow.appendChild(styleEl);
|
|
357
|
+
|
|
358
|
+
const root = document.createElement("div");
|
|
359
|
+
root.className = `cd-root pos-${this.config.position}`;
|
|
360
|
+
this.rootContainer = root;
|
|
361
|
+
|
|
362
|
+
// 1. Compact Minimized Pinning Bar (visible while pinning elements on the page or minimized)
|
|
363
|
+
const compactBar = document.createElement("div");
|
|
364
|
+
compactBar.className = "cd-compact-bar";
|
|
365
|
+
compactBar.setAttribute("role", "region");
|
|
366
|
+
compactBar.setAttribute("aria-label", "ContextDrop Pinning Bar");
|
|
367
|
+
compactBar.hidden = true;
|
|
368
|
+
this.compactBarEl = compactBar;
|
|
369
|
+
|
|
370
|
+
const compactGrip = document.createElement("span");
|
|
371
|
+
compactGrip.className = "cd-pin-dock-grip";
|
|
372
|
+
compactGrip.title = "Drag to move pinning bar";
|
|
373
|
+
compactGrip.innerHTML = ICON_GRIP;
|
|
374
|
+
this.attachDragHandle(compactBar);
|
|
375
|
+
compactBar.appendChild(compactGrip);
|
|
376
|
+
|
|
377
|
+
const compactPinBtn = document.createElement("button");
|
|
378
|
+
compactPinBtn.type = "button";
|
|
379
|
+
compactPinBtn.className = "cd-pin-corner-btn is-active";
|
|
380
|
+
compactPinBtn.setAttribute("aria-label", "Toggle element pin mode");
|
|
381
|
+
compactPinBtn.innerHTML = `${ICON_PIN}<span>Pinning</span>`;
|
|
382
|
+
compactPinBtn.addEventListener("click", () => this.togglePinSelection());
|
|
383
|
+
this.compactPinBtn = compactPinBtn;
|
|
384
|
+
compactBar.appendChild(compactPinBtn);
|
|
385
|
+
|
|
386
|
+
const compactStatus = document.createElement("div");
|
|
387
|
+
compactStatus.className = "cd-compact-status";
|
|
388
|
+
const breadcrumbScroll = document.createElement("div");
|
|
389
|
+
breadcrumbScroll.className = "cd-breadcrumb-scroll";
|
|
390
|
+
this.compactBreadcrumbEl = breadcrumbScroll;
|
|
391
|
+
compactStatus.appendChild(breadcrumbScroll);
|
|
392
|
+
compactBar.appendChild(compactStatus);
|
|
393
|
+
|
|
394
|
+
const compactLayerUp = document.createElement("button");
|
|
395
|
+
compactLayerUp.type = "button";
|
|
396
|
+
compactLayerUp.className = "cd-icon-btn";
|
|
397
|
+
compactLayerUp.title = "Step to outer/parent element (Up arrow)";
|
|
398
|
+
compactLayerUp.setAttribute("aria-label", "Step to parent element");
|
|
399
|
+
compactLayerUp.innerHTML = ICON_CHEVRON_UP;
|
|
400
|
+
compactLayerUp.addEventListener("click", () => this.picker?.cycleDepth(1));
|
|
401
|
+
compactBar.appendChild(compactLayerUp);
|
|
402
|
+
|
|
403
|
+
const compactLayerDown = document.createElement("button");
|
|
404
|
+
compactLayerDown.type = "button";
|
|
405
|
+
compactLayerDown.className = "cd-icon-btn";
|
|
406
|
+
compactLayerDown.title = "Step to inner/child element (Down arrow)";
|
|
407
|
+
compactLayerDown.setAttribute("aria-label", "Step to child element");
|
|
408
|
+
compactLayerDown.innerHTML = ICON_CHEVRON_DOWN;
|
|
409
|
+
compactLayerDown.addEventListener("click", () => this.picker?.cycleDepth(-1));
|
|
410
|
+
compactBar.appendChild(compactLayerDown);
|
|
411
|
+
|
|
412
|
+
const expandBtn = document.createElement("button");
|
|
413
|
+
expandBtn.type = "button";
|
|
414
|
+
expandBtn.className = "cd-btn cd-btn-primary";
|
|
415
|
+
expandBtn.setAttribute("aria-label", "Return to chatbox");
|
|
416
|
+
expandBtn.innerHTML = `${ICON_EXPAND}<span>Chat</span>`;
|
|
417
|
+
expandBtn.addEventListener("click", () => this.expandFromCompactBar());
|
|
418
|
+
compactBar.appendChild(expandBtn);
|
|
419
|
+
|
|
420
|
+
// 2. Main Floating Chatbox Composer Window
|
|
421
|
+
const composer = document.createElement("div");
|
|
422
|
+
composer.className = "cd-composer";
|
|
423
|
+
composer.setAttribute("role", "dialog");
|
|
424
|
+
composer.setAttribute("aria-label", "ContextDrop Feedback Chatbox");
|
|
425
|
+
composer.hidden = !this.isOpen;
|
|
426
|
+
this.composerEl = composer;
|
|
427
|
+
|
|
428
|
+
// Drag-and-drop image support on the chatbox
|
|
429
|
+
composer.addEventListener("dragover", (ev) => {
|
|
430
|
+
ev.preventDefault();
|
|
431
|
+
composer.classList.add("is-dragging-over");
|
|
432
|
+
});
|
|
433
|
+
composer.addEventListener("dragleave", () => {
|
|
434
|
+
composer.classList.remove("is-dragging-over");
|
|
435
|
+
});
|
|
436
|
+
composer.addEventListener("drop", (ev) => {
|
|
437
|
+
ev.preventDefault();
|
|
438
|
+
composer.classList.remove("is-dragging-over");
|
|
439
|
+
const files = ev.dataTransfer?.files;
|
|
440
|
+
if (files && files.length > 0) {
|
|
441
|
+
void this.handleFilesSelected(Array.from(files));
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// Header with Top-Left Pin-Shaped Selector & Drag Handle
|
|
446
|
+
const header = document.createElement("div");
|
|
447
|
+
header.className = "cd-composer-header";
|
|
448
|
+
this.attachDragHandle(header);
|
|
449
|
+
|
|
450
|
+
const headerLeft = document.createElement("div");
|
|
451
|
+
headerLeft.className = "cd-header-left";
|
|
452
|
+
|
|
453
|
+
if (this.config.features.element_selection) {
|
|
454
|
+
const cornerPinBtn = document.createElement("button");
|
|
455
|
+
cornerPinBtn.type = "button";
|
|
456
|
+
cornerPinBtn.className = "cd-pin-corner-btn";
|
|
457
|
+
cornerPinBtn.title = "Pin any HTML element on the page as context";
|
|
458
|
+
cornerPinBtn.setAttribute("aria-label", "Select DOM element on page");
|
|
459
|
+
cornerPinBtn.innerHTML = `${ICON_PIN}<span>Pin Element</span>`;
|
|
460
|
+
cornerPinBtn.addEventListener("click", () => this.startPinSelection());
|
|
461
|
+
this.cornerPinBtn = cornerPinBtn;
|
|
462
|
+
headerLeft.appendChild(cornerPinBtn);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const title = document.createElement("h2");
|
|
466
|
+
title.className = "cd-composer-title";
|
|
467
|
+
title.textContent = "ContextDrop";
|
|
468
|
+
headerLeft.appendChild(title);
|
|
469
|
+
|
|
470
|
+
const headerActions = document.createElement("div");
|
|
471
|
+
headerActions.className = "cd-header-actions";
|
|
472
|
+
|
|
473
|
+
const minBtn = document.createElement("button");
|
|
474
|
+
minBtn.type = "button";
|
|
475
|
+
minBtn.className = "cd-icon-btn";
|
|
476
|
+
minBtn.title = "Minimize to compact bar";
|
|
477
|
+
minBtn.setAttribute("aria-label", "Minimize chatbox to compact bar");
|
|
478
|
+
minBtn.innerHTML = ICON_MINIMIZE;
|
|
479
|
+
minBtn.addEventListener("click", () => this.minimizeToCompactBar());
|
|
480
|
+
|
|
481
|
+
const closeBtn = document.createElement("button");
|
|
482
|
+
closeBtn.type = "button";
|
|
483
|
+
closeBtn.className = "cd-icon-btn";
|
|
484
|
+
closeBtn.title = "Close chatbox (Esc)";
|
|
485
|
+
closeBtn.setAttribute("aria-label", "Close feedback composer");
|
|
486
|
+
closeBtn.innerHTML = ICON_CLOSE;
|
|
487
|
+
closeBtn.addEventListener("click", () => this.closeComposer());
|
|
488
|
+
|
|
489
|
+
headerActions.appendChild(minBtn);
|
|
490
|
+
headerActions.appendChild(closeBtn);
|
|
491
|
+
header.appendChild(headerLeft);
|
|
492
|
+
header.appendChild(headerActions);
|
|
493
|
+
composer.appendChild(header);
|
|
494
|
+
|
|
495
|
+
// Conversational Chat Thread
|
|
496
|
+
const chatThread = document.createElement("div");
|
|
497
|
+
chatThread.className = "cd-chat-thread";
|
|
498
|
+
chatThread.setAttribute("aria-live", "polite");
|
|
499
|
+
this.chatThreadEl = chatThread;
|
|
500
|
+
composer.appendChild(chatThread);
|
|
501
|
+
this.renderChatThread();
|
|
502
|
+
|
|
503
|
+
// Pinned Elements & Attachments Chip Tray
|
|
504
|
+
const tray = document.createElement("div");
|
|
505
|
+
tray.className = "cd-tray";
|
|
506
|
+
tray.setAttribute("aria-label", "Attached context items");
|
|
507
|
+
this.trayEl = tray;
|
|
508
|
+
composer.appendChild(tray);
|
|
509
|
+
|
|
510
|
+
// Status message bar
|
|
511
|
+
const status = document.createElement("div");
|
|
512
|
+
status.className = "cd-status";
|
|
513
|
+
status.setAttribute("role", "status");
|
|
514
|
+
status.hidden = true;
|
|
515
|
+
this.statusEl = status;
|
|
516
|
+
composer.appendChild(status);
|
|
517
|
+
|
|
518
|
+
// Hidden file input
|
|
519
|
+
const fileInput = document.createElement("input");
|
|
520
|
+
fileInput.type = "file";
|
|
521
|
+
fileInput.multiple = true;
|
|
522
|
+
fileInput.accept = this.config.limits.allowed_content_types.join(",");
|
|
523
|
+
fileInput.hidden = true;
|
|
524
|
+
fileInput.addEventListener("change", () => {
|
|
525
|
+
if (fileInput.files) {
|
|
526
|
+
void this.handleFilesSelected(Array.from(fileInput.files));
|
|
527
|
+
fileInput.value = "";
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
this.fileInputEl = fileInput;
|
|
531
|
+
composer.appendChild(fileInput);
|
|
532
|
+
|
|
533
|
+
// Chatbox Prompt Input Shell
|
|
534
|
+
const promptShell = document.createElement("div");
|
|
535
|
+
promptShell.className = "cd-prompt-shell";
|
|
536
|
+
|
|
537
|
+
const textarea = document.createElement("textarea");
|
|
538
|
+
textarea.className = "cd-textarea";
|
|
539
|
+
textarea.placeholder = this.config.placeholder;
|
|
540
|
+
textarea.setAttribute("aria-label", "Feedback description");
|
|
541
|
+
textarea.addEventListener("keydown", (ev) => {
|
|
542
|
+
if ((ev.metaKey || ev.ctrlKey) && ev.key === "Enter") {
|
|
543
|
+
ev.preventDefault();
|
|
544
|
+
void this.handleSubmit();
|
|
545
|
+
} else if (ev.key === "Escape") {
|
|
546
|
+
ev.preventDefault();
|
|
547
|
+
this.closeComposer();
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
textarea.addEventListener("paste", (ev) => this.handlePaste(ev));
|
|
551
|
+
this.textareaEl = textarea;
|
|
552
|
+
promptShell.appendChild(textarea);
|
|
553
|
+
|
|
554
|
+
// Toolbar inside prompt shell
|
|
555
|
+
const toolbar = document.createElement("div");
|
|
556
|
+
toolbar.className = "cd-toolbar";
|
|
557
|
+
|
|
558
|
+
const actions = document.createElement("div");
|
|
559
|
+
actions.className = "cd-toolbar-actions";
|
|
560
|
+
|
|
561
|
+
if (this.config.features.element_selection) {
|
|
562
|
+
const pickBtn = document.createElement("button");
|
|
563
|
+
pickBtn.type = "button";
|
|
564
|
+
pickBtn.className = "cd-btn";
|
|
565
|
+
pickBtn.setAttribute("aria-label", "Pin HTML element as context");
|
|
566
|
+
pickBtn.innerHTML = `${ICON_PIN}<span>Pin</span>`;
|
|
567
|
+
pickBtn.addEventListener("click", () => this.startPinSelection());
|
|
568
|
+
actions.appendChild(pickBtn);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (this.config.features.screenshot_capture) {
|
|
572
|
+
const screenshotBtn = document.createElement("button");
|
|
573
|
+
screenshotBtn.type = "button";
|
|
574
|
+
screenshotBtn.className = "cd-btn";
|
|
575
|
+
screenshotBtn.setAttribute("aria-label", "Capture viewport screenshot");
|
|
576
|
+
screenshotBtn.innerHTML = `${ICON_CAMERA}<span>Screenshot</span>`;
|
|
577
|
+
screenshotBtn.addEventListener("click", () => {
|
|
578
|
+
void this.handleViewportScreenshot();
|
|
579
|
+
});
|
|
580
|
+
actions.appendChild(screenshotBtn);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const uploadBtn = document.createElement("button");
|
|
584
|
+
uploadBtn.type = "button";
|
|
585
|
+
uploadBtn.className = "cd-btn";
|
|
586
|
+
uploadBtn.setAttribute("aria-label", "Upload image attachment");
|
|
587
|
+
uploadBtn.innerHTML = `${ICON_IMAGE}<span>Image</span>`;
|
|
588
|
+
uploadBtn.addEventListener("click", () => this.fileInputEl?.click());
|
|
589
|
+
actions.appendChild(uploadBtn);
|
|
590
|
+
|
|
591
|
+
const submitBtn = document.createElement("button");
|
|
592
|
+
submitBtn.type = "button";
|
|
593
|
+
submitBtn.className = "cd-btn cd-btn-primary";
|
|
594
|
+
submitBtn.setAttribute("aria-label", "Submit feedback");
|
|
595
|
+
submitBtn.innerHTML = `${ICON_SEND}<span>Send</span>`;
|
|
596
|
+
submitBtn.addEventListener("click", () => {
|
|
597
|
+
void this.handleSubmit();
|
|
598
|
+
});
|
|
599
|
+
this.submitBtn = submitBtn;
|
|
600
|
+
|
|
601
|
+
toolbar.appendChild(actions);
|
|
602
|
+
toolbar.appendChild(submitBtn);
|
|
603
|
+
promptShell.appendChild(toolbar);
|
|
604
|
+
composer.appendChild(promptShell);
|
|
605
|
+
|
|
606
|
+
// 3. Freely Draggable Floating Pin Dock (visible when chatbox is closed, or draggable launcher)
|
|
607
|
+
const pinDock = document.createElement("div");
|
|
608
|
+
pinDock.className = "cd-pin-dock";
|
|
609
|
+
this.pinDockEl = pinDock;
|
|
610
|
+
|
|
611
|
+
const dockGrip = document.createElement("span");
|
|
612
|
+
dockGrip.className = "cd-pin-dock-grip";
|
|
613
|
+
dockGrip.title = "Drag to float anywhere on screen";
|
|
614
|
+
dockGrip.innerHTML = ICON_GRIP;
|
|
615
|
+
this.attachDragHandle(dockGrip);
|
|
616
|
+
pinDock.appendChild(dockGrip);
|
|
617
|
+
|
|
618
|
+
if (this.config.features.element_selection) {
|
|
619
|
+
const floatingPinBtn = document.createElement("button");
|
|
620
|
+
floatingPinBtn.type = "button";
|
|
621
|
+
floatingPinBtn.className = "cd-floating-pin-btn";
|
|
622
|
+
floatingPinBtn.title = "Pin an element on the page (or drag to move)";
|
|
623
|
+
floatingPinBtn.setAttribute("aria-label", "Pin element on page");
|
|
624
|
+
floatingPinBtn.innerHTML = `${ICON_PIN}<span>Pin</span>`;
|
|
625
|
+
this.attachDragHandle(floatingPinBtn, () => this.startPinSelection());
|
|
626
|
+
this.floatingPinBtn = floatingPinBtn;
|
|
627
|
+
pinDock.appendChild(floatingPinBtn);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const trigger = document.createElement("button");
|
|
631
|
+
trigger.type = "button";
|
|
632
|
+
trigger.className = "cd-trigger";
|
|
633
|
+
trigger.setAttribute("aria-label", "Open feedback composer");
|
|
634
|
+
trigger.setAttribute("aria-expanded", String(this.isOpen));
|
|
635
|
+
trigger.innerHTML = `${ICON_CHAT}<span>ContextDrop</span>`;
|
|
636
|
+
this.attachDragHandle(trigger, () => this.toggleComposer());
|
|
637
|
+
this.triggerBtn = trigger;
|
|
638
|
+
pinDock.appendChild(trigger);
|
|
639
|
+
|
|
640
|
+
root.appendChild(compactBar);
|
|
641
|
+
root.appendChild(composer);
|
|
642
|
+
root.appendChild(pinDock);
|
|
643
|
+
this.shadow.appendChild(root);
|
|
644
|
+
|
|
645
|
+
this.renderCompactBreadcrumbs(null, []);
|
|
646
|
+
this.syncUiStates();
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
private syncPinActiveIndicators(pickerActive: boolean): void {
|
|
650
|
+
if (this.cornerPinBtn) {
|
|
651
|
+
this.cornerPinBtn.classList.toggle("is-active", pickerActive);
|
|
652
|
+
this.cornerPinBtn.setAttribute("aria-pressed", String(pickerActive));
|
|
653
|
+
}
|
|
654
|
+
if (this.floatingPinBtn) {
|
|
655
|
+
this.floatingPinBtn.classList.toggle("is-active", pickerActive);
|
|
656
|
+
this.floatingPinBtn.setAttribute("aria-pressed", String(pickerActive));
|
|
657
|
+
}
|
|
658
|
+
if (this.compactPinBtn) {
|
|
659
|
+
this.compactPinBtn.classList.toggle("is-active", pickerActive);
|
|
660
|
+
this.compactPinBtn.innerHTML = pickerActive
|
|
661
|
+
? `${ICON_PIN}<span>Pinning</span>`
|
|
662
|
+
: `${ICON_PIN}<span>Pin Element</span>`;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
private syncUiStates(): void {
|
|
667
|
+
const pickerActive = this.picker ? this.picker.isActive() : false;
|
|
668
|
+
const totalContextCount = this.elementReferences.length + this.attachments.length;
|
|
669
|
+
|
|
670
|
+
if (this.composerEl) {
|
|
671
|
+
this.composerEl.hidden = !this.isOpen || this.isMinimized;
|
|
672
|
+
}
|
|
673
|
+
if (this.compactBarEl) {
|
|
674
|
+
this.compactBarEl.hidden = !(this.isOpen && this.isMinimized);
|
|
675
|
+
}
|
|
676
|
+
if (this.pinDockEl) {
|
|
677
|
+
this.pinDockEl.hidden = this.isOpen;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (this.triggerBtn) {
|
|
681
|
+
this.triggerBtn.setAttribute("aria-expanded", String(this.isOpen && !this.isMinimized));
|
|
682
|
+
const countBadge =
|
|
683
|
+
totalContextCount > 0 ? `<span class="cd-count-pill">${totalContextCount}</span>` : "";
|
|
684
|
+
this.triggerBtn.innerHTML = `${ICON_CHAT}<span>ContextDrop</span>${countBadge}`;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if (this.cornerPinBtn) {
|
|
688
|
+
const pinCount =
|
|
689
|
+
this.elementReferences.length > 0
|
|
690
|
+
? `<span class="cd-count-pill">${this.elementReferences.length}</span>`
|
|
691
|
+
: "";
|
|
692
|
+
this.cornerPinBtn.innerHTML = `${ICON_PIN}<span>Pin Element</span>${pinCount}`;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
this.syncPinActiveIndicators(pickerActive);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
private renderCompactBreadcrumbs(
|
|
699
|
+
activeCandidate: ElementCandidateInfo | null,
|
|
700
|
+
ancestors: Element[]
|
|
701
|
+
): void {
|
|
702
|
+
if (!this.compactBreadcrumbEl) return;
|
|
703
|
+
this.compactBreadcrumbEl.innerHTML = "";
|
|
704
|
+
|
|
705
|
+
if (!activeCandidate || ancestors.length === 0) {
|
|
706
|
+
const hint = document.createElement("span");
|
|
707
|
+
hint.style.cssText = "font-size: 11.5px; color: #4b5262;";
|
|
708
|
+
hint.textContent =
|
|
709
|
+
this.elementReferences.length > 0
|
|
710
|
+
? `${this.elementReferences.length} pinned · Hover or click any element (scroll ↕ for layers)`
|
|
711
|
+
: "Hover any element · Scroll ↕ for layers · Click to inspect stack";
|
|
712
|
+
this.compactBreadcrumbEl.appendChild(hint);
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
ancestors.forEach((ancEl, idx) => {
|
|
717
|
+
if (idx > 0) {
|
|
718
|
+
const sep = document.createElement("span");
|
|
719
|
+
sep.className = "cd-crumb-sep";
|
|
720
|
+
sep.innerHTML = ICON_CHEVRON_RIGHT;
|
|
721
|
+
this.compactBreadcrumbEl?.appendChild(sep);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const isCurrent = ancEl === activeCandidate.element;
|
|
725
|
+
const crumb = document.createElement("button");
|
|
726
|
+
crumb.type = "button";
|
|
727
|
+
crumb.className = `cd-crumb-btn${isCurrent ? " is-current" : ""}`;
|
|
728
|
+
crumb.textContent = computeSelector(ancEl);
|
|
729
|
+
crumb.title = "Click to pin this element directly";
|
|
730
|
+
crumb.addEventListener("mouseenter", () => {
|
|
731
|
+
this.picker?.focusCandidateElement(ancEl);
|
|
732
|
+
});
|
|
733
|
+
crumb.addEventListener("click", () => {
|
|
734
|
+
this.picker?.selectElementDirectly(ancEl);
|
|
735
|
+
});
|
|
736
|
+
this.compactBreadcrumbEl?.appendChild(crumb);
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
private renderChatThread(): void {
|
|
741
|
+
if (!this.chatThreadEl) return;
|
|
742
|
+
this.chatThreadEl.innerHTML = "";
|
|
743
|
+
|
|
744
|
+
if (this.sessionHistory.length === 0) {
|
|
745
|
+
const emptyBox = document.createElement("div");
|
|
746
|
+
emptyBox.className = "cd-chat-empty";
|
|
747
|
+
|
|
748
|
+
const p1 = document.createElement("p");
|
|
749
|
+
p1.className = "cd-chat-empty-text";
|
|
750
|
+
p1.textContent = "Drop context directly from this page.";
|
|
751
|
+
|
|
752
|
+
const p2 = document.createElement("p");
|
|
753
|
+
p2.className = "cd-chat-empty-hint";
|
|
754
|
+
p2.textContent =
|
|
755
|
+
"Use the top-left Pin selector to pick specific HTML elements (scroll or click to disambiguate overlapping layers), attach screenshots, and send your note.";
|
|
756
|
+
|
|
757
|
+
emptyBox.appendChild(p1);
|
|
758
|
+
emptyBox.appendChild(p2);
|
|
759
|
+
this.chatThreadEl.appendChild(emptyBox);
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
for (const entry of this.sessionHistory) {
|
|
764
|
+
const msgCard = document.createElement("div");
|
|
765
|
+
msgCard.className = "cd-chat-msg";
|
|
766
|
+
|
|
767
|
+
if (entry.elements.length > 0 || entry.attachments.length > 0) {
|
|
768
|
+
const pills = document.createElement("div");
|
|
769
|
+
pills.className = "cd-chat-msg-pills";
|
|
770
|
+
|
|
771
|
+
for (const el of entry.elements) {
|
|
772
|
+
const pill = document.createElement("span");
|
|
773
|
+
pill.className = "cd-chat-msg-pill";
|
|
774
|
+
pill.textContent = el.selector;
|
|
775
|
+
pills.appendChild(pill);
|
|
776
|
+
}
|
|
777
|
+
for (const att of entry.attachments) {
|
|
778
|
+
const pill = document.createElement("span");
|
|
779
|
+
pill.className = "cd-chat-msg-pill";
|
|
780
|
+
pill.textContent = att.label;
|
|
781
|
+
pills.appendChild(pill);
|
|
782
|
+
}
|
|
783
|
+
msgCard.appendChild(pills);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
if (entry.message) {
|
|
787
|
+
const textP = document.createElement("p");
|
|
788
|
+
textP.className = "cd-chat-msg-text";
|
|
789
|
+
textP.textContent = entry.message;
|
|
790
|
+
msgCard.appendChild(textP);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const meta = document.createElement("div");
|
|
794
|
+
meta.className = "cd-chat-msg-meta";
|
|
795
|
+
const idSpan = document.createElement("span");
|
|
796
|
+
idSpan.textContent = `Dropped #${entry.id}`;
|
|
797
|
+
const timeSpan = document.createElement("span");
|
|
798
|
+
timeSpan.textContent = entry.createdAt;
|
|
799
|
+
meta.appendChild(idSpan);
|
|
800
|
+
meta.appendChild(timeSpan);
|
|
801
|
+
msgCard.appendChild(meta);
|
|
802
|
+
|
|
803
|
+
this.chatThreadEl.appendChild(msgCard);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
this.chatThreadEl.scrollTop = this.chatThreadEl.scrollHeight;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
private renderTray(): void {
|
|
810
|
+
if (!this.trayEl) return;
|
|
811
|
+
this.trayEl.innerHTML = "";
|
|
812
|
+
|
|
813
|
+
// Render ElementReference chips
|
|
814
|
+
for (const elRef of this.elementReferences) {
|
|
815
|
+
const chip = document.createElement("div");
|
|
816
|
+
chip.className = "cd-chip";
|
|
817
|
+
chip.setAttribute("data-chip-type", "element");
|
|
818
|
+
|
|
819
|
+
const domNode = this.elementDomNodes.get(elRef.client_ref);
|
|
820
|
+
if (domNode) {
|
|
821
|
+
chip.addEventListener("mouseenter", () => this.picker?.showExternalPreview(domNode));
|
|
822
|
+
chip.addEventListener("mouseleave", () => this.picker?.hideExternalPreview());
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const badge = document.createElement("span");
|
|
826
|
+
badge.className = "cd-chip-badge";
|
|
827
|
+
badge.innerHTML = `${ICON_PIN}<span>Pin</span>`;
|
|
828
|
+
|
|
829
|
+
const label = document.createElement("span");
|
|
830
|
+
label.className = "cd-chip-label";
|
|
831
|
+
label.textContent = elRef.selector;
|
|
832
|
+
label.title = elRef.dom_path || elRef.selector;
|
|
833
|
+
|
|
834
|
+
chip.appendChild(badge);
|
|
835
|
+
chip.appendChild(label);
|
|
836
|
+
|
|
837
|
+
if (
|
|
838
|
+
this.config.features.screenshot_capture &&
|
|
839
|
+
domNode
|
|
840
|
+
) {
|
|
841
|
+
const snapBtn = document.createElement("button");
|
|
842
|
+
snapBtn.type = "button";
|
|
843
|
+
snapBtn.className = "cd-chip-action";
|
|
844
|
+
snapBtn.title = "Capture element screenshot";
|
|
845
|
+
snapBtn.setAttribute("aria-label", `Capture screenshot of ${elRef.selector}`);
|
|
846
|
+
snapBtn.innerHTML = ICON_CAMERA;
|
|
847
|
+
snapBtn.addEventListener("click", () => {
|
|
848
|
+
void this.handleElementScreenshot(domNode, elRef.client_ref, elRef.selector);
|
|
849
|
+
});
|
|
850
|
+
chip.appendChild(snapBtn);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const removeBtn = document.createElement("button");
|
|
854
|
+
removeBtn.type = "button";
|
|
855
|
+
removeBtn.className = "cd-chip-remove";
|
|
856
|
+
removeBtn.setAttribute("aria-label", `Remove ${elRef.selector}`);
|
|
857
|
+
removeBtn.innerHTML = ICON_CLOSE;
|
|
858
|
+
removeBtn.addEventListener("click", () => this.removeElementReference(elRef.client_ref));
|
|
859
|
+
|
|
860
|
+
chip.appendChild(removeBtn);
|
|
861
|
+
this.trayEl.appendChild(chip);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// Render Attachment chips (uploaded images & screenshots)
|
|
865
|
+
for (const att of this.attachments) {
|
|
866
|
+
const chip = document.createElement("div");
|
|
867
|
+
chip.className = "cd-chip";
|
|
868
|
+
chip.setAttribute("data-chip-type", att.attachment_type);
|
|
869
|
+
|
|
870
|
+
if (att.preview_url) {
|
|
871
|
+
const img = document.createElement("img");
|
|
872
|
+
img.className = "cd-chip-thumb";
|
|
873
|
+
img.src = att.preview_url;
|
|
874
|
+
img.alt = att.label;
|
|
875
|
+
chip.appendChild(img);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const badge = document.createElement("span");
|
|
879
|
+
badge.className = "cd-chip-badge";
|
|
880
|
+
badge.textContent = att.attachment_type === "screenshot" ? "Shot" : "Image";
|
|
881
|
+
|
|
882
|
+
const label = document.createElement("span");
|
|
883
|
+
label.className = "cd-chip-label";
|
|
884
|
+
label.textContent = att.label;
|
|
885
|
+
|
|
886
|
+
const removeBtn = document.createElement("button");
|
|
887
|
+
removeBtn.type = "button";
|
|
888
|
+
removeBtn.className = "cd-chip-remove";
|
|
889
|
+
removeBtn.setAttribute("aria-label", `Remove ${att.label}`);
|
|
890
|
+
removeBtn.innerHTML = ICON_CLOSE;
|
|
891
|
+
removeBtn.addEventListener("click", () => this.removeAttachment(att.id));
|
|
892
|
+
|
|
893
|
+
chip.appendChild(badge);
|
|
894
|
+
chip.appendChild(label);
|
|
895
|
+
chip.appendChild(removeBtn);
|
|
896
|
+
this.trayEl.appendChild(chip);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
private async handleFilesSelected(files: File[]): Promise<void> {
|
|
901
|
+
for (const file of files) {
|
|
902
|
+
if (this.attachments.length >= this.config.limits.max_attachments) {
|
|
903
|
+
this.showStatus(`Maximum of ${this.config.limits.max_attachments} attachments reached.`, "error");
|
|
904
|
+
break;
|
|
905
|
+
}
|
|
906
|
+
if (!this.config.limits.allowed_content_types.includes(file.type)) {
|
|
907
|
+
this.showStatus(`Unsupported file type: ${file.type || file.name}`, "error");
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
if (file.size > this.config.limits.max_attachment_size) {
|
|
911
|
+
this.showStatus(`File ${file.name} exceeds size limit.`, "error");
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const dataUrl = await this.readFileAsDataUrl(file);
|
|
916
|
+
this.attachments.push({
|
|
917
|
+
id: `att_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
918
|
+
attachment_type: "image",
|
|
919
|
+
label: file.name,
|
|
920
|
+
preview_url: dataUrl,
|
|
921
|
+
blob: file,
|
|
922
|
+
data_url: dataUrl,
|
|
923
|
+
filename: file.name,
|
|
924
|
+
metadata: {
|
|
925
|
+
original_filename: file.name,
|
|
926
|
+
byte_size: file.size
|
|
927
|
+
}
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
this.renderTray();
|
|
931
|
+
this.syncUiStates();
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
private handlePaste(event: ClipboardEvent): void {
|
|
935
|
+
const items = event.clipboardData?.items;
|
|
936
|
+
if (!items) return;
|
|
937
|
+
|
|
938
|
+
const imageFiles: File[] = [];
|
|
939
|
+
for (let i = 0; i < items.length; i += 1) {
|
|
940
|
+
const item = items[i];
|
|
941
|
+
if (item.type.startsWith("image/")) {
|
|
942
|
+
const file = item.getAsFile();
|
|
943
|
+
if (file) imageFiles.push(file);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
if (imageFiles.length > 0) {
|
|
947
|
+
void this.handleFilesSelected(imageFiles);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
private readFileAsDataUrl(file: Blob): Promise<string> {
|
|
952
|
+
return new Promise((resolve, reject) => {
|
|
953
|
+
const reader = new FileReader();
|
|
954
|
+
reader.onload = () => resolve(String(reader.result || ""));
|
|
955
|
+
reader.onerror = () => reject(new Error("Failed to read image file"));
|
|
956
|
+
reader.readAsDataURL(file);
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
private async handleViewportScreenshot(): Promise<void> {
|
|
961
|
+
if (this.attachments.length >= this.config.limits.max_attachments) {
|
|
962
|
+
this.showStatus(`Maximum of ${this.config.limits.max_attachments} attachments reached.`, "error");
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
try {
|
|
967
|
+
const result = await captureViewportScreenshot(this.config.privacy.excluded_selectors);
|
|
968
|
+
this.attachments.push({
|
|
969
|
+
id: `shot_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
|
970
|
+
attachment_type: "screenshot",
|
|
971
|
+
label: `Viewport (${result.width}×${result.height})`,
|
|
972
|
+
preview_url: result.dataUrl,
|
|
973
|
+
blob: result.blob,
|
|
974
|
+
data_url: result.dataUrl,
|
|
975
|
+
filename: `viewport-${Date.now()}.png`,
|
|
976
|
+
metadata: result.metadata
|
|
977
|
+
});
|
|
978
|
+
this.renderTray();
|
|
979
|
+
this.syncUiStates();
|
|
980
|
+
} catch (err) {
|
|
981
|
+
const message = err instanceof Error ? err.message : "Screenshot capture unavailable.";
|
|
982
|
+
this.showStatus(message, "error");
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
private async handleElementScreenshot(
|
|
987
|
+
element: Element,
|
|
988
|
+
elementClientRef: string,
|
|
989
|
+
selectorLabel: string
|
|
990
|
+
): Promise<void> {
|
|
991
|
+
if (this.attachments.length >= this.config.limits.max_attachments) {
|
|
992
|
+
this.showStatus(`Maximum of ${this.config.limits.max_attachments} attachments reached.`, "error");
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
try {
|
|
997
|
+
const result = await captureElementScreenshot(
|
|
998
|
+
element,
|
|
999
|
+
this.config.features.element_capture_padding,
|
|
1000
|
+
this.config.privacy.excluded_selectors,
|
|
1001
|
+
elementClientRef
|
|
1002
|
+
);
|
|
1003
|
+
this.attachments.push({
|
|
1004
|
+
id: `elshot_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
|
1005
|
+
attachment_type: "screenshot",
|
|
1006
|
+
label: `Element ${selectorLabel}`,
|
|
1007
|
+
preview_url: result.dataUrl,
|
|
1008
|
+
blob: result.blob,
|
|
1009
|
+
data_url: result.dataUrl,
|
|
1010
|
+
filename: `element-${Date.now()}.png`,
|
|
1011
|
+
element_reference_ref: elementClientRef,
|
|
1012
|
+
metadata: result.metadata
|
|
1013
|
+
});
|
|
1014
|
+
this.renderTray();
|
|
1015
|
+
this.syncUiStates();
|
|
1016
|
+
} catch (err) {
|
|
1017
|
+
const message = err instanceof Error ? err.message : "Element screenshot failed.";
|
|
1018
|
+
this.showStatus(message, "error");
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
public async handleSubmit(): Promise<void> {
|
|
1023
|
+
if (this.isSubmitting) return;
|
|
1024
|
+
|
|
1025
|
+
const message = (this.textareaEl?.value || "").trim();
|
|
1026
|
+
if (
|
|
1027
|
+
message.length === 0 &&
|
|
1028
|
+
this.attachments.length === 0 &&
|
|
1029
|
+
this.elementReferences.length === 0
|
|
1030
|
+
) {
|
|
1031
|
+
this.showStatus("Enter a note, pin an element, or attach a screenshot first.", "error");
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
this.isSubmitting = true;
|
|
1036
|
+
if (this.submitBtn) {
|
|
1037
|
+
this.submitBtn.disabled = true;
|
|
1038
|
+
this.submitBtn.innerHTML = `<span>Sending...</span>`;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
try {
|
|
1042
|
+
const submittedElements = [...this.elementReferences];
|
|
1043
|
+
const submittedAttachments = this.attachments.map((att) => ({
|
|
1044
|
+
id: att.id,
|
|
1045
|
+
label: att.label,
|
|
1046
|
+
preview_url: att.preview_url,
|
|
1047
|
+
attachment_type: att.attachment_type
|
|
1048
|
+
}));
|
|
1049
|
+
|
|
1050
|
+
const payload: FeedbackSubmissionPayload = {
|
|
1051
|
+
message,
|
|
1052
|
+
page_url: typeof window !== "undefined" ? window.location.href : "/",
|
|
1053
|
+
user_agent: typeof navigator !== "undefined" ? navigator.userAgent : "",
|
|
1054
|
+
metadata: {
|
|
1055
|
+
...this.customMetadata,
|
|
1056
|
+
viewport: {
|
|
1057
|
+
width: typeof window !== "undefined" ? window.innerWidth : 0,
|
|
1058
|
+
height: typeof window !== "undefined" ? window.innerHeight : 0
|
|
1059
|
+
},
|
|
1060
|
+
locale: typeof navigator !== "undefined" ? navigator.language : "en"
|
|
1061
|
+
},
|
|
1062
|
+
element_references: this.elementReferences,
|
|
1063
|
+
attachments: this.attachments.map((att) => ({
|
|
1064
|
+
attachment_type: att.attachment_type,
|
|
1065
|
+
filename: att.filename,
|
|
1066
|
+
signed_blob_id: att.signed_blob_id,
|
|
1067
|
+
data_url: att.signed_blob_id ? undefined : att.data_url,
|
|
1068
|
+
element_reference_ref: att.element_reference_ref,
|
|
1069
|
+
metadata: att.metadata
|
|
1070
|
+
}))
|
|
1071
|
+
};
|
|
1072
|
+
|
|
1073
|
+
const res = await this.apiClient.submitFeedback(payload);
|
|
1074
|
+
|
|
1075
|
+
const now = new Date();
|
|
1076
|
+
const timeLabel = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
|
1077
|
+
this.sessionHistory.push({
|
|
1078
|
+
id: res?.id ?? this.sessionHistory.length + 1,
|
|
1079
|
+
message: message || "(Context drop without text)",
|
|
1080
|
+
createdAt: timeLabel,
|
|
1081
|
+
elements: submittedElements,
|
|
1082
|
+
attachments: submittedAttachments
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
if (this.textareaEl) {
|
|
1086
|
+
this.textareaEl.value = "";
|
|
1087
|
+
}
|
|
1088
|
+
this.elementReferences = [];
|
|
1089
|
+
this.elementDomNodes.clear();
|
|
1090
|
+
this.attachments = [];
|
|
1091
|
+
this.picker?.hideExternalPreview();
|
|
1092
|
+
this.renderTray();
|
|
1093
|
+
this.renderChatThread();
|
|
1094
|
+
this.syncUiStates();
|
|
1095
|
+
this.showStatus("Context drop saved.", "success");
|
|
1096
|
+
} catch (err) {
|
|
1097
|
+
const msg = err instanceof Error ? err.message : "Failed to submit feedback.";
|
|
1098
|
+
this.showStatus(msg, "error");
|
|
1099
|
+
} finally {
|
|
1100
|
+
this.isSubmitting = false;
|
|
1101
|
+
if (this.submitBtn) {
|
|
1102
|
+
this.submitBtn.disabled = false;
|
|
1103
|
+
this.submitBtn.innerHTML = `${ICON_SEND}<span>Send</span>`;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
private showStatus(text: string, kind: "error" | "success"): void {
|
|
1109
|
+
if (!this.statusEl) return;
|
|
1110
|
+
this.statusEl.textContent = text;
|
|
1111
|
+
this.statusEl.className = `cd-status ${kind}`;
|
|
1112
|
+
this.statusEl.hidden = false;
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
export function registerContextDropWidget(): void {
|
|
1117
|
+
if (typeof customElements !== "undefined" && !customElements.get("context-drop-widget")) {
|
|
1118
|
+
customElements.define("context-drop-widget", ContextDropWidgetElement);
|
|
1119
|
+
}
|
|
1120
|
+
}
|