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,1004 @@
|
|
|
1
|
+
import type { ElementCandidateInfo, ElementReferenceSnapshot } from "./types.ts";
|
|
2
|
+
import {
|
|
3
|
+
captureElementSnapshot,
|
|
4
|
+
computeSelector,
|
|
5
|
+
describeElementCandidate,
|
|
6
|
+
isSelectableElement,
|
|
7
|
+
resolveAncestorChain,
|
|
8
|
+
resolveCandidateStack
|
|
9
|
+
} from "./sanitizer.ts";
|
|
10
|
+
import {
|
|
11
|
+
ICON_CHECK,
|
|
12
|
+
ICON_CHEVRON_DOWN,
|
|
13
|
+
ICON_CHEVRON_RIGHT,
|
|
14
|
+
ICON_CHEVRON_UP,
|
|
15
|
+
ICON_CLOSE,
|
|
16
|
+
ICON_LAYERS,
|
|
17
|
+
ICON_PIN
|
|
18
|
+
} from "./icons.ts";
|
|
19
|
+
|
|
20
|
+
export interface ElementPickerCallbacks {
|
|
21
|
+
onSelect: (snapshot: ElementReferenceSnapshot, element: Element) => void;
|
|
22
|
+
onExit: () => void;
|
|
23
|
+
canSelectMore: () => boolean;
|
|
24
|
+
getExcludedSelectors: () => string[];
|
|
25
|
+
onStateChange?: (state: {
|
|
26
|
+
active: boolean;
|
|
27
|
+
anchored: boolean;
|
|
28
|
+
activeCandidate: ElementCandidateInfo | null;
|
|
29
|
+
candidateStack: ElementCandidateInfo[];
|
|
30
|
+
ancestors: Element[];
|
|
31
|
+
}) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class ElementPicker {
|
|
35
|
+
private active: boolean = false;
|
|
36
|
+
private anchored: boolean = false;
|
|
37
|
+
private anchorX: number = 0;
|
|
38
|
+
private anchorY: number = 0;
|
|
39
|
+
private lastPointerX: number = 0;
|
|
40
|
+
private lastPointerY: number = 0;
|
|
41
|
+
private wheelAccumulator: number = 0;
|
|
42
|
+
|
|
43
|
+
private callbacks: ElementPickerCallbacks;
|
|
44
|
+
private overlayEl: HTMLDivElement | null = null;
|
|
45
|
+
private leafOutlineEl: HTMLDivElement | null = null;
|
|
46
|
+
private labelEl: HTMLDivElement | null = null;
|
|
47
|
+
private pinMarkerEl: HTMLDivElement | null = null;
|
|
48
|
+
private popoverEl: HTMLDivElement | null = null;
|
|
49
|
+
private previewOverlayEl: HTMLDivElement | null = null;
|
|
50
|
+
|
|
51
|
+
private candidateStack: ElementCandidateInfo[] = [];
|
|
52
|
+
private activeCandidateIndex: number = 0;
|
|
53
|
+
private leafElement: Element | null = null;
|
|
54
|
+
private previousCursor: string = "";
|
|
55
|
+
|
|
56
|
+
private boundMouseMove: (e: MouseEvent) => void;
|
|
57
|
+
private boundClickCapture: (e: MouseEvent) => void;
|
|
58
|
+
private boundPointerCapture: (e: Event) => void;
|
|
59
|
+
private boundWheelCapture: (e: WheelEvent) => void;
|
|
60
|
+
private boundKeyDown: (e: KeyboardEvent) => void;
|
|
61
|
+
private boundScrollOrResize: () => void;
|
|
62
|
+
|
|
63
|
+
constructor(callbacks: ElementPickerCallbacks) {
|
|
64
|
+
this.callbacks = callbacks;
|
|
65
|
+
this.boundMouseMove = this.handleMouseMove.bind(this);
|
|
66
|
+
this.boundClickCapture = this.handleClickCapture.bind(this);
|
|
67
|
+
this.boundPointerCapture = this.handlePointerSuppression.bind(this);
|
|
68
|
+
this.boundWheelCapture = this.handleWheelCapture.bind(this);
|
|
69
|
+
this.boundKeyDown = this.handleKeyDown.bind(this);
|
|
70
|
+
this.boundScrollOrResize = this.updateOverlayPosition.bind(this);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
public isActive(): boolean {
|
|
74
|
+
return this.active;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
public isAnchored(): boolean {
|
|
78
|
+
return this.anchored;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
public getActiveCandidate(): ElementCandidateInfo | null {
|
|
82
|
+
return this.candidateStack[this.activeCandidateIndex] || null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
public getCandidateStack(): ElementCandidateInfo[] {
|
|
86
|
+
return [...this.candidateStack];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
public start(): void {
|
|
90
|
+
if (this.active || typeof document === "undefined" || typeof window === "undefined") {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
this.active = true;
|
|
94
|
+
this.anchored = false;
|
|
95
|
+
this.candidateStack = [];
|
|
96
|
+
this.activeCandidateIndex = 0;
|
|
97
|
+
this.leafElement = null;
|
|
98
|
+
|
|
99
|
+
this.hideExternalPreview();
|
|
100
|
+
|
|
101
|
+
this.previousCursor = document.body ? document.body.style.cursor : "";
|
|
102
|
+
if (document.body) {
|
|
103
|
+
document.body.style.cursor = "crosshair";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
this.mountSelectionUi();
|
|
107
|
+
this.notifyStateChange();
|
|
108
|
+
|
|
109
|
+
window.addEventListener("mousemove", this.boundMouseMove, { capture: true, passive: true });
|
|
110
|
+
window.addEventListener("pointerdown", this.boundPointerCapture, { capture: true });
|
|
111
|
+
window.addEventListener("mousedown", this.boundPointerCapture, { capture: true });
|
|
112
|
+
window.addEventListener("mouseup", this.boundPointerCapture, { capture: true });
|
|
113
|
+
window.addEventListener("click", this.boundClickCapture, { capture: true });
|
|
114
|
+
window.addEventListener("wheel", this.boundWheelCapture, { capture: true, passive: false });
|
|
115
|
+
window.addEventListener("keydown", this.boundKeyDown, { capture: true });
|
|
116
|
+
window.addEventListener("scroll", this.boundScrollOrResize, { capture: true, passive: true });
|
|
117
|
+
window.addEventListener("resize", this.boundScrollOrResize, { passive: true });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
public stop(): void {
|
|
121
|
+
if (!this.active) return;
|
|
122
|
+
this.active = false;
|
|
123
|
+
this.anchored = false;
|
|
124
|
+
this.candidateStack = [];
|
|
125
|
+
this.activeCandidateIndex = 0;
|
|
126
|
+
this.leafElement = null;
|
|
127
|
+
|
|
128
|
+
if (typeof document !== "undefined" && document.body) {
|
|
129
|
+
document.body.style.cursor = this.previousCursor;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (typeof window !== "undefined") {
|
|
133
|
+
window.removeEventListener("mousemove", this.boundMouseMove, { capture: true });
|
|
134
|
+
window.removeEventListener("pointerdown", this.boundPointerCapture, { capture: true });
|
|
135
|
+
window.removeEventListener("mousedown", this.boundPointerCapture, { capture: true });
|
|
136
|
+
window.removeEventListener("mouseup", this.boundPointerCapture, { capture: true });
|
|
137
|
+
window.removeEventListener("click", this.boundClickCapture, { capture: true });
|
|
138
|
+
window.removeEventListener("wheel", this.boundWheelCapture, { capture: true });
|
|
139
|
+
window.removeEventListener("keydown", this.boundKeyDown, { capture: true });
|
|
140
|
+
window.removeEventListener("scroll", this.boundScrollOrResize, { capture: true });
|
|
141
|
+
window.removeEventListener("resize", this.boundScrollOrResize);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
this.unmountSelectionUi();
|
|
145
|
+
this.notifyStateChange();
|
|
146
|
+
this.callbacks.onExit();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Allows the widget or breadcrumb bar to select or preview a specific DOM element directly.
|
|
151
|
+
*/
|
|
152
|
+
public focusCandidateElement(el: Element): void {
|
|
153
|
+
if (!this.active) return;
|
|
154
|
+
const excluded = this.callbacks.getExcludedSelectors();
|
|
155
|
+
if (!isSelectableElement(el, excluded)) return;
|
|
156
|
+
|
|
157
|
+
const existingIdx = this.candidateStack.findIndex((c) => c.element === el);
|
|
158
|
+
if (existingIdx !== -1) {
|
|
159
|
+
this.activeCandidateIndex = existingIdx;
|
|
160
|
+
} else {
|
|
161
|
+
const info = describeElementCandidate(el, "ancestor", this.candidateStack.length, excluded);
|
|
162
|
+
this.candidateStack.push(info);
|
|
163
|
+
this.activeCandidateIndex = this.candidateStack.length - 1;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
this.updateOverlayPosition();
|
|
167
|
+
if (this.anchored) {
|
|
168
|
+
this.renderLayerPopover();
|
|
169
|
+
}
|
|
170
|
+
this.notifyStateChange();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
public selectElementDirectly(el: Element): boolean {
|
|
174
|
+
const excluded = this.callbacks.getExcludedSelectors();
|
|
175
|
+
const snapshot = captureElementSnapshot(el, excluded);
|
|
176
|
+
if (!snapshot) return false;
|
|
177
|
+
|
|
178
|
+
this.callbacks.onSelect(snapshot, el);
|
|
179
|
+
this.closeLayerPopover();
|
|
180
|
+
|
|
181
|
+
if (!this.callbacks.canSelectMore()) {
|
|
182
|
+
this.stop();
|
|
183
|
+
} else {
|
|
184
|
+
this.notifyStateChange();
|
|
185
|
+
}
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
public showExternalPreview(el: Element | null): void {
|
|
190
|
+
if (typeof document === "undefined" || !document.body) return;
|
|
191
|
+
if (!el || this.active) {
|
|
192
|
+
this.hideExternalPreview();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const rect = el.getBoundingClientRect();
|
|
197
|
+
if (rect.width <= 0 || rect.height <= 0) {
|
|
198
|
+
this.hideExternalPreview();
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!this.previewOverlayEl) {
|
|
203
|
+
const preview = document.createElement("div");
|
|
204
|
+
preview.setAttribute("data-context-drop-ui", "chip-preview");
|
|
205
|
+
preview.style.cssText = [
|
|
206
|
+
"position: fixed",
|
|
207
|
+
"pointer-events: none",
|
|
208
|
+
"z-index: 2147483644",
|
|
209
|
+
"border: 2px solid #c2410c",
|
|
210
|
+
"background: rgba(234, 88, 12, 0.10)",
|
|
211
|
+
"border-radius: 6px",
|
|
212
|
+
"transition: all 0.12s cubic-bezier(0.22, 1, 0.36, 1)"
|
|
213
|
+
].join(";");
|
|
214
|
+
document.body.appendChild(preview);
|
|
215
|
+
this.previewOverlayEl = preview;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
this.previewOverlayEl.style.display = "block";
|
|
219
|
+
this.previewOverlayEl.style.left = `${rect.left}px`;
|
|
220
|
+
this.previewOverlayEl.style.top = `${rect.top}px`;
|
|
221
|
+
this.previewOverlayEl.style.width = `${rect.width}px`;
|
|
222
|
+
this.previewOverlayEl.style.height = `${rect.height}px`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
public hideExternalPreview(): void {
|
|
226
|
+
if (this.previewOverlayEl && this.previewOverlayEl.parentNode) {
|
|
227
|
+
this.previewOverlayEl.parentNode.removeChild(this.previewOverlayEl);
|
|
228
|
+
}
|
|
229
|
+
this.previewOverlayEl = null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
public cycleDepth(delta: number): void {
|
|
233
|
+
if (!this.active || this.candidateStack.length <= 1) return;
|
|
234
|
+
const next = Math.max(
|
|
235
|
+
0,
|
|
236
|
+
Math.min(this.candidateStack.length - 1, this.activeCandidateIndex + delta)
|
|
237
|
+
);
|
|
238
|
+
if (next !== this.activeCandidateIndex) {
|
|
239
|
+
this.activeCandidateIndex = next;
|
|
240
|
+
this.updateOverlayPosition();
|
|
241
|
+
if (this.anchored) {
|
|
242
|
+
this.renderLayerPopover();
|
|
243
|
+
}
|
|
244
|
+
this.notifyStateChange();
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private notifyStateChange(): void {
|
|
249
|
+
if (!this.callbacks.onStateChange) return;
|
|
250
|
+
const activeCandidate = this.getActiveCandidate();
|
|
251
|
+
const ancestors = activeCandidate
|
|
252
|
+
? resolveAncestorChain(activeCandidate.element, this.callbacks.getExcludedSelectors())
|
|
253
|
+
: [];
|
|
254
|
+
this.callbacks.onStateChange({
|
|
255
|
+
active: this.active,
|
|
256
|
+
anchored: this.anchored,
|
|
257
|
+
activeCandidate,
|
|
258
|
+
candidateStack: [...this.candidateStack],
|
|
259
|
+
ancestors
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private mountSelectionUi(): void {
|
|
264
|
+
if (typeof document === "undefined" || !document.body) return;
|
|
265
|
+
|
|
266
|
+
// Secondary ghost outline showing the innermost leaf element when cycling up to parent/overlapping layers
|
|
267
|
+
const leafOutline = document.createElement("div");
|
|
268
|
+
leafOutline.setAttribute("data-context-drop-ui", "leaf-outline");
|
|
269
|
+
leafOutline.style.cssText = [
|
|
270
|
+
"position: fixed",
|
|
271
|
+
"pointer-events: none",
|
|
272
|
+
"z-index: 2147483644",
|
|
273
|
+
"border: 1px dashed rgba(29, 78, 216, 0.55)",
|
|
274
|
+
"background: rgba(29, 78, 216, 0.03)",
|
|
275
|
+
"border-radius: 4px",
|
|
276
|
+
"display: none"
|
|
277
|
+
].join(";");
|
|
278
|
+
|
|
279
|
+
// Primary active element highlight box
|
|
280
|
+
const overlay = document.createElement("div");
|
|
281
|
+
overlay.setAttribute("data-context-drop-ui", "overlay");
|
|
282
|
+
overlay.style.cssText = [
|
|
283
|
+
"position: fixed",
|
|
284
|
+
"pointer-events: none",
|
|
285
|
+
"z-index: 2147483645",
|
|
286
|
+
"border: 2px solid #1d4ed8",
|
|
287
|
+
"background: rgba(29, 78, 216, 0.10)",
|
|
288
|
+
"border-radius: 6px",
|
|
289
|
+
"display: none",
|
|
290
|
+
"transition: left 0.06s cubic-bezier(0.22, 1, 0.36, 1), top 0.06s cubic-bezier(0.22, 1, 0.36, 1), width 0.06s cubic-bezier(0.22, 1, 0.36, 1), height 0.06s cubic-bezier(0.22, 1, 0.36, 1)"
|
|
291
|
+
].join(";");
|
|
292
|
+
|
|
293
|
+
// Floating HUD badge on the highlight box
|
|
294
|
+
const label = document.createElement("div");
|
|
295
|
+
label.setAttribute("data-context-drop-ui", "label");
|
|
296
|
+
label.style.cssText = [
|
|
297
|
+
"position: absolute",
|
|
298
|
+
"top: -30px",
|
|
299
|
+
"left: -2px",
|
|
300
|
+
"background: #111318",
|
|
301
|
+
"color: #faf9f6",
|
|
302
|
+
"font-family: ui-monospace, SFMono-Regular, Menlo, monospace",
|
|
303
|
+
"font-size: 11px",
|
|
304
|
+
"font-variant-numeric: tabular-nums",
|
|
305
|
+
"line-height: 1.3",
|
|
306
|
+
"padding: 4px 8px",
|
|
307
|
+
"border-radius: 6px",
|
|
308
|
+
"white-space: nowrap",
|
|
309
|
+
"pointer-events: none",
|
|
310
|
+
"display: inline-flex",
|
|
311
|
+
"align-items: center",
|
|
312
|
+
"gap: 6px",
|
|
313
|
+
"box-shadow: 0 8px 18px -4px rgba(17, 19, 24, 0.28)"
|
|
314
|
+
].join(";");
|
|
315
|
+
overlay.appendChild(label);
|
|
316
|
+
|
|
317
|
+
// Floating Pin marker that follows the pointer or anchors at the clicked coordinate
|
|
318
|
+
const pinMarker = document.createElement("div");
|
|
319
|
+
pinMarker.setAttribute("data-context-drop-ui", "pin-marker");
|
|
320
|
+
pinMarker.style.cssText = [
|
|
321
|
+
"position: fixed",
|
|
322
|
+
"pointer-events: none",
|
|
323
|
+
"z-index: 2147483646",
|
|
324
|
+
"width: 28px",
|
|
325
|
+
"height: 28px",
|
|
326
|
+
"margin-left: -6px",
|
|
327
|
+
"margin-top: -24px",
|
|
328
|
+
"border-radius: 9999px 9999px 9999px 3px",
|
|
329
|
+
"background: #c2410c",
|
|
330
|
+
"color: #faf9f6",
|
|
331
|
+
"border: 1.5px solid #faf9f6",
|
|
332
|
+
"display: none",
|
|
333
|
+
"align-items: center",
|
|
334
|
+
"justify-content: center",
|
|
335
|
+
"box-shadow: 0 8px 16px -3px rgba(17, 19, 24, 0.32)",
|
|
336
|
+
"transition: transform 0.12s cubic-bezier(0.22, 1, 0.36, 1)"
|
|
337
|
+
].join(";");
|
|
338
|
+
pinMarker.innerHTML = ICON_PIN;
|
|
339
|
+
|
|
340
|
+
// Layer & Hierarchy Disambiguation Popover (anchors when clicking overlapping/cascading elements)
|
|
341
|
+
const popover = document.createElement("div");
|
|
342
|
+
popover.setAttribute("data-context-drop-ui", "layer-popover");
|
|
343
|
+
popover.style.cssText = [
|
|
344
|
+
"position: fixed",
|
|
345
|
+
"z-index: 2147483647",
|
|
346
|
+
"width: 348px",
|
|
347
|
+
"max-width: calc(100vw - 24px)",
|
|
348
|
+
"background: #faf9f6",
|
|
349
|
+
"color: #111318",
|
|
350
|
+
"border: 1px solid #d6d3cd",
|
|
351
|
+
"border-radius: 12px",
|
|
352
|
+
"box-shadow: 0 22px 44px -12px rgba(17, 19, 24, 0.26), 0 6px 14px -4px rgba(17, 19, 24, 0.10)",
|
|
353
|
+
"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
|
354
|
+
"font-size: 12px",
|
|
355
|
+
"display: none",
|
|
356
|
+
"overflow: hidden",
|
|
357
|
+
"pointer-events: auto"
|
|
358
|
+
].join(";");
|
|
359
|
+
|
|
360
|
+
document.body.appendChild(leafOutline);
|
|
361
|
+
document.body.appendChild(overlay);
|
|
362
|
+
document.body.appendChild(pinMarker);
|
|
363
|
+
document.body.appendChild(popover);
|
|
364
|
+
|
|
365
|
+
this.leafOutlineEl = leafOutline;
|
|
366
|
+
this.overlayEl = overlay;
|
|
367
|
+
this.labelEl = label;
|
|
368
|
+
this.pinMarkerEl = pinMarker;
|
|
369
|
+
this.popoverEl = popover;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private unmountSelectionUi(): void {
|
|
373
|
+
for (const el of [this.leafOutlineEl, this.overlayEl, this.pinMarkerEl, this.popoverEl]) {
|
|
374
|
+
if (el && el.parentNode) {
|
|
375
|
+
el.parentNode.removeChild(el);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
this.leafOutlineEl = null;
|
|
379
|
+
this.overlayEl = null;
|
|
380
|
+
this.labelEl = null;
|
|
381
|
+
this.pinMarkerEl = null;
|
|
382
|
+
this.popoverEl = null;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private isInternalUiEvent(event: Event): boolean {
|
|
386
|
+
const path = typeof event.composedPath === "function" ? event.composedPath() : [];
|
|
387
|
+
for (const node of path) {
|
|
388
|
+
if (node instanceof Element) {
|
|
389
|
+
if (
|
|
390
|
+
node.tagName.toLowerCase() === "context-drop-widget" ||
|
|
391
|
+
node.hasAttribute("data-context-drop-ui") ||
|
|
392
|
+
(typeof node.closest === "function" &&
|
|
393
|
+
(node.closest("context-drop-widget") || node.closest("[data-context-drop-ui]")))
|
|
394
|
+
) {
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
const rawTarget = event.target instanceof Element ? event.target : null;
|
|
400
|
+
if (
|
|
401
|
+
rawTarget &&
|
|
402
|
+
typeof rawTarget.closest === "function" &&
|
|
403
|
+
(rawTarget.closest("context-drop-widget") || rawTarget.closest("[data-context-drop-ui]"))
|
|
404
|
+
) {
|
|
405
|
+
return true;
|
|
406
|
+
}
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private collectPointElements(clientX: number, clientY: number, event?: Event): Element[] {
|
|
411
|
+
const results: Element[] = [];
|
|
412
|
+
if (typeof document !== "undefined" && typeof document.elementsFromPoint === "function") {
|
|
413
|
+
try {
|
|
414
|
+
const fromPoint = document.elementsFromPoint(clientX, clientY);
|
|
415
|
+
for (const el of fromPoint) {
|
|
416
|
+
if (el instanceof Element) {
|
|
417
|
+
results.push(el);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
} catch {
|
|
421
|
+
// Fallback to composedPath below
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (event && typeof event.composedPath === "function") {
|
|
425
|
+
for (const node of event.composedPath()) {
|
|
426
|
+
if (node instanceof Element && !results.includes(node)) {
|
|
427
|
+
results.push(node);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (event && event.target instanceof Element && !results.includes(event.target)) {
|
|
432
|
+
results.unshift(event.target);
|
|
433
|
+
}
|
|
434
|
+
return results;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
private updateCandidatesFromPoint(clientX: number, clientY: number, event?: Event): void {
|
|
438
|
+
const excluded = this.callbacks.getExcludedSelectors();
|
|
439
|
+
const pointElements = this.collectPointElements(clientX, clientY, event);
|
|
440
|
+
const primary = pointElements.find((el) => isSelectableElement(el, excluded)) || null;
|
|
441
|
+
|
|
442
|
+
if (!primary) {
|
|
443
|
+
this.leafElement = null;
|
|
444
|
+
this.candidateStack = [];
|
|
445
|
+
this.activeCandidateIndex = 0;
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Preserve depth selection if user is hovering inside the exact same primary leaf element
|
|
450
|
+
if (this.leafElement === primary && this.candidateStack.length > 0) {
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
this.leafElement = primary;
|
|
455
|
+
this.candidateStack = resolveCandidateStack(primary, pointElements, excluded, 10);
|
|
456
|
+
this.activeCandidateIndex = 0;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
private handleMouseMove(event: MouseEvent): void {
|
|
460
|
+
if (!this.active) return;
|
|
461
|
+
|
|
462
|
+
this.lastPointerX = event.clientX;
|
|
463
|
+
this.lastPointerY = event.clientY;
|
|
464
|
+
|
|
465
|
+
if (this.anchored) {
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (this.isInternalUiEvent(event)) {
|
|
470
|
+
if (this.overlayEl) this.overlayEl.style.display = "none";
|
|
471
|
+
if (this.leafOutlineEl) this.leafOutlineEl.style.display = "none";
|
|
472
|
+
if (this.pinMarkerEl) this.pinMarkerEl.style.display = "none";
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (this.pinMarkerEl) {
|
|
477
|
+
this.pinMarkerEl.style.display = "inline-flex";
|
|
478
|
+
this.pinMarkerEl.style.left = `${event.clientX}px`;
|
|
479
|
+
this.pinMarkerEl.style.top = `${event.clientY}px`;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
this.updateCandidatesFromPoint(event.clientX, event.clientY, event);
|
|
483
|
+
this.updateOverlayPosition();
|
|
484
|
+
this.notifyStateChange();
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
private updateOverlayPosition(): void {
|
|
488
|
+
if (!this.overlayEl || !this.labelEl) return;
|
|
489
|
+
|
|
490
|
+
const currentCandidate = this.getActiveCandidate();
|
|
491
|
+
if (!currentCandidate) {
|
|
492
|
+
this.overlayEl.style.display = "none";
|
|
493
|
+
if (this.leafOutlineEl) this.leafOutlineEl.style.display = "none";
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const rect = currentCandidate.element.getBoundingClientRect();
|
|
498
|
+
if (rect.width <= 0 || rect.height <= 0) {
|
|
499
|
+
this.overlayEl.style.display = "none";
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
this.overlayEl.style.display = "block";
|
|
504
|
+
this.overlayEl.style.left = `${rect.left}px`;
|
|
505
|
+
this.overlayEl.style.top = `${rect.top}px`;
|
|
506
|
+
this.overlayEl.style.width = `${rect.width}px`;
|
|
507
|
+
this.overlayEl.style.height = `${rect.height}px`;
|
|
508
|
+
|
|
509
|
+
// Position the HUD pill inside the top of the box if the element is near the top of the viewport
|
|
510
|
+
if (rect.top < 36) {
|
|
511
|
+
this.labelEl.style.top = "6px";
|
|
512
|
+
this.labelEl.style.left = "6px";
|
|
513
|
+
} else {
|
|
514
|
+
this.labelEl.style.top = "-30px";
|
|
515
|
+
this.labelEl.style.left = "-2px";
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const total = this.candidateStack.length;
|
|
519
|
+
const pos = this.activeCandidateIndex + 1;
|
|
520
|
+
const dims = `${Math.round(rect.width)}×${Math.round(rect.height)}`;
|
|
521
|
+
const depthBadge = total > 1 ? ` · ${pos}/${total} layers (↕ scroll)` : "";
|
|
522
|
+
this.labelEl.textContent = `${currentCandidate.tagName}${currentCandidate.selector.startsWith(currentCandidate.tagName) ? currentCandidate.selector.slice(currentCandidate.tagName.length) : ` ${currentCandidate.selector}`} · ${dims}${depthBadge}`;
|
|
523
|
+
|
|
524
|
+
// Show ghost leaf outline when inspecting an ancestor or overlapping layer
|
|
525
|
+
if (this.leafOutlineEl) {
|
|
526
|
+
if (this.activeCandidateIndex > 0 && this.leafElement && this.leafElement !== currentCandidate.element) {
|
|
527
|
+
const leafRect = this.leafElement.getBoundingClientRect();
|
|
528
|
+
this.leafOutlineEl.style.display = "block";
|
|
529
|
+
this.leafOutlineEl.style.left = `${leafRect.left}px`;
|
|
530
|
+
this.leafOutlineEl.style.top = `${leafRect.top}px`;
|
|
531
|
+
this.leafOutlineEl.style.width = `${leafRect.width}px`;
|
|
532
|
+
this.leafOutlineEl.style.height = `${leafRect.height}px`;
|
|
533
|
+
} else {
|
|
534
|
+
this.leafOutlineEl.style.display = "none";
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
private handleWheelCapture(event: WheelEvent): void {
|
|
540
|
+
if (!this.active) return;
|
|
541
|
+
|
|
542
|
+
// Allow natural scrolling inside the layer popover or widget itself
|
|
543
|
+
if (this.isInternalUiEvent(event)) {
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (this.candidateStack.length <= 1) {
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
event.preventDefault();
|
|
552
|
+
event.stopPropagation();
|
|
553
|
+
|
|
554
|
+
this.wheelAccumulator += event.deltaY;
|
|
555
|
+
if (Math.abs(this.wheelAccumulator) >= 24) {
|
|
556
|
+
const step = this.wheelAccumulator > 0 ? 1 : -1;
|
|
557
|
+
this.wheelAccumulator = 0;
|
|
558
|
+
this.cycleDepth(step);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
private handlePointerSuppression(event: Event): void {
|
|
563
|
+
if (!this.active) return;
|
|
564
|
+
if (this.isInternalUiEvent(event)) {
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Intercept host page pointer events in capture phase so links/buttons do not fire
|
|
569
|
+
event.preventDefault();
|
|
570
|
+
event.stopPropagation();
|
|
571
|
+
if (typeof event.stopImmediatePropagation === "function") {
|
|
572
|
+
event.stopImmediatePropagation();
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
private handleClickCapture(event: MouseEvent): void {
|
|
577
|
+
if (!this.active) return;
|
|
578
|
+
if (this.isInternalUiEvent(event)) {
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
event.preventDefault();
|
|
583
|
+
event.stopPropagation();
|
|
584
|
+
if (typeof event.stopImmediatePropagation === "function") {
|
|
585
|
+
event.stopImmediatePropagation();
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// If popover was already anchored and user clicked elsewhere on the page, re-evaluate at the new click point
|
|
589
|
+
this.anchorX = event.clientX;
|
|
590
|
+
this.anchorY = event.clientY;
|
|
591
|
+
this.updateCandidatesFromPoint(event.clientX, event.clientY, event);
|
|
592
|
+
|
|
593
|
+
const activeCandidate = this.getActiveCandidate();
|
|
594
|
+
if (!activeCandidate) return;
|
|
595
|
+
|
|
596
|
+
// Fast-path: Shift+Click or single-layer target pins immediately
|
|
597
|
+
if (event.shiftKey || this.candidateStack.length === 1) {
|
|
598
|
+
this.selectElementDirectly(activeCandidate.element);
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Otherwise anchor the floating pin and open the Layer & Hierarchy Disambiguation Popover
|
|
603
|
+
this.anchored = true;
|
|
604
|
+
if (this.pinMarkerEl) {
|
|
605
|
+
this.pinMarkerEl.style.display = "inline-flex";
|
|
606
|
+
this.pinMarkerEl.style.left = `${this.anchorX}px`;
|
|
607
|
+
this.pinMarkerEl.style.top = `${this.anchorY}px`;
|
|
608
|
+
this.pinMarkerEl.style.transform = "scale(1.08)";
|
|
609
|
+
}
|
|
610
|
+
this.updateOverlayPosition();
|
|
611
|
+
this.renderLayerPopover();
|
|
612
|
+
this.notifyStateChange();
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
public closeLayerPopover(): void {
|
|
616
|
+
this.anchored = false;
|
|
617
|
+
if (this.popoverEl) {
|
|
618
|
+
this.popoverEl.style.display = "none";
|
|
619
|
+
this.popoverEl.innerHTML = "";
|
|
620
|
+
}
|
|
621
|
+
if (this.pinMarkerEl) {
|
|
622
|
+
this.pinMarkerEl.style.transform = "scale(1)";
|
|
623
|
+
this.pinMarkerEl.style.left = `${this.lastPointerX}px`;
|
|
624
|
+
this.pinMarkerEl.style.top = `${this.lastPointerY}px`;
|
|
625
|
+
}
|
|
626
|
+
this.notifyStateChange();
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
private renderLayerPopover(): void {
|
|
630
|
+
if (!this.popoverEl || typeof window === "undefined") return;
|
|
631
|
+
const activeCandidate = this.getActiveCandidate();
|
|
632
|
+
if (!activeCandidate) return;
|
|
633
|
+
|
|
634
|
+
this.popoverEl.innerHTML = "";
|
|
635
|
+
this.popoverEl.style.display = "block";
|
|
636
|
+
|
|
637
|
+
// Header: title + Pin Current CTA + close popover
|
|
638
|
+
const header = document.createElement("div");
|
|
639
|
+
header.style.cssText = [
|
|
640
|
+
"display: flex",
|
|
641
|
+
"align-items: center",
|
|
642
|
+
"justify-content: space-between",
|
|
643
|
+
"gap: 8px",
|
|
644
|
+
"padding: 10px 12px",
|
|
645
|
+
"background: #f1efea",
|
|
646
|
+
"border-bottom: 1px solid #d6d3cd"
|
|
647
|
+
].join(";");
|
|
648
|
+
|
|
649
|
+
const titleGroup = document.createElement("div");
|
|
650
|
+
titleGroup.style.cssText = "display: flex; align-items: center; gap: 6px; font-weight: 600; font-size: 12px; color: #111318;";
|
|
651
|
+
const layersIconSpan = document.createElement("span");
|
|
652
|
+
layersIconSpan.style.cssText = "display: inline-flex; color: #c2410c;";
|
|
653
|
+
layersIconSpan.innerHTML = ICON_LAYERS;
|
|
654
|
+
const titleText = document.createElement("span");
|
|
655
|
+
titleText.textContent = `Choose Element Layer (${this.candidateStack.length})`;
|
|
656
|
+
titleGroup.appendChild(layersIconSpan);
|
|
657
|
+
titleGroup.appendChild(titleText);
|
|
658
|
+
|
|
659
|
+
const headerActions = document.createElement("div");
|
|
660
|
+
headerActions.style.cssText = "display: flex; align-items: center; gap: 4px;";
|
|
661
|
+
|
|
662
|
+
const stepUpBtn = document.createElement("button");
|
|
663
|
+
stepUpBtn.type = "button";
|
|
664
|
+
stepUpBtn.title = "Step to parent/outer layer (Up arrow)";
|
|
665
|
+
stepUpBtn.setAttribute("aria-label", "Step to outer layer");
|
|
666
|
+
stepUpBtn.style.cssText = [
|
|
667
|
+
"display: inline-flex",
|
|
668
|
+
"align-items: center",
|
|
669
|
+
"justify-content: center",
|
|
670
|
+
"width: 24px",
|
|
671
|
+
"height: 24px",
|
|
672
|
+
"border-radius: 6px",
|
|
673
|
+
"border: 1px solid #d6d3cd",
|
|
674
|
+
"background: #faf9f6",
|
|
675
|
+
"color: #111318",
|
|
676
|
+
"cursor: pointer"
|
|
677
|
+
].join(";");
|
|
678
|
+
stepUpBtn.innerHTML = ICON_CHEVRON_UP;
|
|
679
|
+
stepUpBtn.disabled = this.activeCandidateIndex >= this.candidateStack.length - 1;
|
|
680
|
+
stepUpBtn.addEventListener("click", (ev) => {
|
|
681
|
+
ev.stopPropagation();
|
|
682
|
+
this.cycleDepth(1);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
const stepDownBtn = document.createElement("button");
|
|
686
|
+
stepDownBtn.type = "button";
|
|
687
|
+
stepDownBtn.title = "Step to inner/child layer (Down arrow)";
|
|
688
|
+
stepDownBtn.setAttribute("aria-label", "Step to inner layer");
|
|
689
|
+
stepDownBtn.style.cssText = stepUpBtn.style.cssText;
|
|
690
|
+
stepDownBtn.innerHTML = ICON_CHEVRON_DOWN;
|
|
691
|
+
stepDownBtn.disabled = this.activeCandidateIndex <= 0;
|
|
692
|
+
stepDownBtn.addEventListener("click", (ev) => {
|
|
693
|
+
ev.stopPropagation();
|
|
694
|
+
this.cycleDepth(-1);
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
const closeBtn = document.createElement("button");
|
|
698
|
+
closeBtn.type = "button";
|
|
699
|
+
closeBtn.title = "Resume hover selection (Esc)";
|
|
700
|
+
closeBtn.setAttribute("aria-label", "Close layer picker");
|
|
701
|
+
closeBtn.style.cssText = [
|
|
702
|
+
"display: inline-flex",
|
|
703
|
+
"align-items: center",
|
|
704
|
+
"justify-content: center",
|
|
705
|
+
"width: 24px",
|
|
706
|
+
"height: 24px",
|
|
707
|
+
"border-radius: 6px",
|
|
708
|
+
"border: 0",
|
|
709
|
+
"background: transparent",
|
|
710
|
+
"color: #4b5262",
|
|
711
|
+
"cursor: pointer"
|
|
712
|
+
].join(";");
|
|
713
|
+
closeBtn.innerHTML = ICON_CLOSE;
|
|
714
|
+
closeBtn.addEventListener("click", (ev) => {
|
|
715
|
+
ev.stopPropagation();
|
|
716
|
+
this.closeLayerPopover();
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
headerActions.appendChild(stepDownBtn);
|
|
720
|
+
headerActions.appendChild(stepUpBtn);
|
|
721
|
+
headerActions.appendChild(closeBtn);
|
|
722
|
+
header.appendChild(titleGroup);
|
|
723
|
+
header.appendChild(headerActions);
|
|
724
|
+
this.popoverEl.appendChild(header);
|
|
725
|
+
|
|
726
|
+
// Interactive DOM Breadcrumb Bar
|
|
727
|
+
const ancestors = resolveAncestorChain(
|
|
728
|
+
activeCandidate.element,
|
|
729
|
+
this.callbacks.getExcludedSelectors(),
|
|
730
|
+
6
|
|
731
|
+
);
|
|
732
|
+
if (ancestors.length > 0) {
|
|
733
|
+
const breadcrumbBar = document.createElement("div");
|
|
734
|
+
breadcrumbBar.setAttribute("data-context-drop-ui", "breadcrumb-bar");
|
|
735
|
+
breadcrumbBar.style.cssText = [
|
|
736
|
+
"display: flex",
|
|
737
|
+
"align-items: center",
|
|
738
|
+
"flex-wrap: wrap",
|
|
739
|
+
"gap: 3px",
|
|
740
|
+
"padding: 7px 12px",
|
|
741
|
+
"background: #f5f3ee",
|
|
742
|
+
"border-bottom: 1px solid #e4e1da",
|
|
743
|
+
"font-family: ui-monospace, SFMono-Regular, Menlo, monospace",
|
|
744
|
+
"font-size: 11px"
|
|
745
|
+
].join(";");
|
|
746
|
+
|
|
747
|
+
ancestors.forEach((ancEl, idx) => {
|
|
748
|
+
if (idx > 0) {
|
|
749
|
+
const sep = document.createElement("span");
|
|
750
|
+
sep.style.cssText = "display: inline-flex; color: #687082;";
|
|
751
|
+
sep.innerHTML = ICON_CHEVRON_RIGHT;
|
|
752
|
+
breadcrumbBar.appendChild(sep);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const isCurrent = ancEl === activeCandidate.element;
|
|
756
|
+
const crumbBtn = document.createElement("button");
|
|
757
|
+
crumbBtn.type = "button";
|
|
758
|
+
crumbBtn.textContent = computeSelector(ancEl);
|
|
759
|
+
crumbBtn.style.cssText = [
|
|
760
|
+
"appearance: none",
|
|
761
|
+
"border: 1px solid " + (isCurrent ? "#1d4ed8" : "transparent"),
|
|
762
|
+
"background: " + (isCurrent ? "#dbeafe" : "transparent"),
|
|
763
|
+
"color: " + (isCurrent ? "#1e3a8a" : "#4b5262"),
|
|
764
|
+
"font-family: inherit",
|
|
765
|
+
"font-size: 11px",
|
|
766
|
+
"font-weight: " + (isCurrent ? "600" : "500"),
|
|
767
|
+
"padding: 2px 5px",
|
|
768
|
+
"border-radius: 4px",
|
|
769
|
+
"cursor: pointer",
|
|
770
|
+
"max-width: 130px",
|
|
771
|
+
"overflow: hidden",
|
|
772
|
+
"text-overflow: ellipsis",
|
|
773
|
+
"white-space: nowrap"
|
|
774
|
+
].join(";");
|
|
775
|
+
|
|
776
|
+
crumbBtn.addEventListener("mouseenter", () => {
|
|
777
|
+
this.focusCandidateElement(ancEl);
|
|
778
|
+
});
|
|
779
|
+
crumbBtn.addEventListener("click", (ev) => {
|
|
780
|
+
ev.stopPropagation();
|
|
781
|
+
this.selectElementDirectly(ancEl);
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
breadcrumbBar.appendChild(crumbBtn);
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
this.popoverEl.appendChild(breadcrumbBar);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// Layer Stack List (Target, Overlapping siblings, Direct children, Ancestor containers)
|
|
791
|
+
const list = document.createElement("div");
|
|
792
|
+
list.style.cssText = [
|
|
793
|
+
"max-height: 230px",
|
|
794
|
+
"overflow-y: auto",
|
|
795
|
+
"padding: 6px",
|
|
796
|
+
"display: flex",
|
|
797
|
+
"flex-direction: column",
|
|
798
|
+
"gap: 3px"
|
|
799
|
+
].join(";");
|
|
800
|
+
|
|
801
|
+
const relationLabels: Record<string, string> = {
|
|
802
|
+
target: "Topmost",
|
|
803
|
+
overlap: "Overlap",
|
|
804
|
+
child: "Child",
|
|
805
|
+
ancestor: "Parent"
|
|
806
|
+
};
|
|
807
|
+
|
|
808
|
+
this.candidateStack.forEach((candidate, idx) => {
|
|
809
|
+
const isSelected = idx === this.activeCandidateIndex;
|
|
810
|
+
const row = document.createElement("button");
|
|
811
|
+
row.type = "button";
|
|
812
|
+
row.setAttribute("data-context-drop-layer-index", String(idx));
|
|
813
|
+
row.style.cssText = [
|
|
814
|
+
"appearance: none",
|
|
815
|
+
"width: 100%",
|
|
816
|
+
"text-align: left",
|
|
817
|
+
"display: flex",
|
|
818
|
+
"flex-direction: column",
|
|
819
|
+
"gap: 3px",
|
|
820
|
+
"padding: 7px 9px",
|
|
821
|
+
"border-radius: 8px",
|
|
822
|
+
"border: 1px solid " + (isSelected ? "#1d4ed8" : "transparent"),
|
|
823
|
+
"background: " + (isSelected ? "#eff6ff" : "transparent"),
|
|
824
|
+
"color: #111318",
|
|
825
|
+
"cursor: pointer",
|
|
826
|
+
"transition: background-color 0.1s ease"
|
|
827
|
+
].join(";");
|
|
828
|
+
|
|
829
|
+
const topLine = document.createElement("div");
|
|
830
|
+
topLine.style.cssText = "display: flex; align-items: center; justify-content: space-between; gap: 6px; width: 100%;";
|
|
831
|
+
|
|
832
|
+
const leftGroup = document.createElement("div");
|
|
833
|
+
leftGroup.style.cssText = "display: flex; align-items: center; gap: 6px; min-width: 0; flex: 1;";
|
|
834
|
+
|
|
835
|
+
const relBadge = document.createElement("span");
|
|
836
|
+
relBadge.textContent = relationLabels[candidate.relation] || "Layer";
|
|
837
|
+
relBadge.style.cssText = [
|
|
838
|
+
"font-size: 10px",
|
|
839
|
+
"font-weight: 600",
|
|
840
|
+
"padding: 1px 5px",
|
|
841
|
+
"border-radius: 4px",
|
|
842
|
+
"background: " + (candidate.relation === "target" ? "#ffedd5" : candidate.relation === "overlap" ? "#fce7f3" : "#e2e8f0"),
|
|
843
|
+
"color: " + (candidate.relation === "target" ? "#9a3412" : candidate.relation === "overlap" ? "#9d174d" : "#334155"),
|
|
844
|
+
"flex-shrink: 0"
|
|
845
|
+
].join(";");
|
|
846
|
+
|
|
847
|
+
const selectorSpan = document.createElement("span");
|
|
848
|
+
selectorSpan.textContent = `${candidate.tagName}${candidate.selector.startsWith(candidate.tagName) ? candidate.selector.slice(candidate.tagName.length) : candidate.selector}`;
|
|
849
|
+
selectorSpan.style.cssText = [
|
|
850
|
+
"font-family: ui-monospace, SFMono-Regular, Menlo, monospace",
|
|
851
|
+
"font-size: 11.5px",
|
|
852
|
+
"font-weight: 600",
|
|
853
|
+
"color: #111318",
|
|
854
|
+
"overflow: hidden",
|
|
855
|
+
"text-overflow: ellipsis",
|
|
856
|
+
"white-space: nowrap"
|
|
857
|
+
].join(";");
|
|
858
|
+
|
|
859
|
+
leftGroup.appendChild(relBadge);
|
|
860
|
+
leftGroup.appendChild(selectorSpan);
|
|
861
|
+
|
|
862
|
+
const dimsSpan = document.createElement("span");
|
|
863
|
+
dimsSpan.textContent = `${Math.round(candidate.rect.width)}×${Math.round(candidate.rect.height)}`;
|
|
864
|
+
dimsSpan.style.cssText = [
|
|
865
|
+
"font-family: ui-monospace, SFMono-Regular, Menlo, monospace",
|
|
866
|
+
"font-size: 10.5px",
|
|
867
|
+
"font-variant-numeric: tabular-nums",
|
|
868
|
+
"color: #4b5262",
|
|
869
|
+
"flex-shrink: 0"
|
|
870
|
+
].join(";");
|
|
871
|
+
|
|
872
|
+
topLine.appendChild(leftGroup);
|
|
873
|
+
topLine.appendChild(dimsSpan);
|
|
874
|
+
row.appendChild(topLine);
|
|
875
|
+
|
|
876
|
+
if (candidate.textPreview) {
|
|
877
|
+
const previewLine = document.createElement("div");
|
|
878
|
+
previewLine.textContent = `"${candidate.textPreview}"`;
|
|
879
|
+
previewLine.style.cssText = [
|
|
880
|
+
"font-size: 11px",
|
|
881
|
+
"color: #4b5262",
|
|
882
|
+
"overflow: hidden",
|
|
883
|
+
"text-overflow: ellipsis",
|
|
884
|
+
"white-space: nowrap",
|
|
885
|
+
"padding-left: 2px"
|
|
886
|
+
].join(";");
|
|
887
|
+
row.appendChild(previewLine);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
row.addEventListener("mouseenter", () => {
|
|
891
|
+
if (this.activeCandidateIndex !== idx) {
|
|
892
|
+
this.activeCandidateIndex = idx;
|
|
893
|
+
this.updateOverlayPosition();
|
|
894
|
+
this.notifyStateChange();
|
|
895
|
+
}
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
row.addEventListener("click", (ev) => {
|
|
899
|
+
ev.stopPropagation();
|
|
900
|
+
this.selectElementDirectly(candidate.element);
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
list.appendChild(row);
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
this.popoverEl.appendChild(list);
|
|
907
|
+
|
|
908
|
+
// Footer with primary Pin confirmation action
|
|
909
|
+
const footer = document.createElement("div");
|
|
910
|
+
footer.style.cssText = [
|
|
911
|
+
"display: flex",
|
|
912
|
+
"align-items: center",
|
|
913
|
+
"justify-content: space-between",
|
|
914
|
+
"padding: 8px 12px",
|
|
915
|
+
"background: #f1efea",
|
|
916
|
+
"border-top: 1px solid #d6d3cd"
|
|
917
|
+
].join(";");
|
|
918
|
+
|
|
919
|
+
const hint = document.createElement("span");
|
|
920
|
+
hint.textContent = "Click any layer or press Enter";
|
|
921
|
+
hint.style.cssText = "font-size: 11px; color: #4b5262;";
|
|
922
|
+
|
|
923
|
+
const pinNowBtn = document.createElement("button");
|
|
924
|
+
pinNowBtn.type = "button";
|
|
925
|
+
pinNowBtn.setAttribute("data-context-drop-ui", "confirm-pin-btn");
|
|
926
|
+
pinNowBtn.style.cssText = [
|
|
927
|
+
"display: inline-flex",
|
|
928
|
+
"align-items: center",
|
|
929
|
+
"gap: 5px",
|
|
930
|
+
"padding: 5px 10px",
|
|
931
|
+
"border-radius: 6px",
|
|
932
|
+
"border: 0",
|
|
933
|
+
"background: #c2410c",
|
|
934
|
+
"color: #faf9f6",
|
|
935
|
+
"font-size: 11.5px",
|
|
936
|
+
"font-weight: 600",
|
|
937
|
+
"cursor: pointer"
|
|
938
|
+
].join(";");
|
|
939
|
+
pinNowBtn.innerHTML = `${ICON_CHECK}<span>Pin Selected</span>`;
|
|
940
|
+
pinNowBtn.addEventListener("click", (ev) => {
|
|
941
|
+
ev.stopPropagation();
|
|
942
|
+
const target = this.getActiveCandidate();
|
|
943
|
+
if (target) {
|
|
944
|
+
this.selectElementDirectly(target.element);
|
|
945
|
+
}
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
footer.appendChild(hint);
|
|
949
|
+
footer.appendChild(pinNowBtn);
|
|
950
|
+
this.popoverEl.appendChild(footer);
|
|
951
|
+
|
|
952
|
+
// Clamp popover inside viewport bounds near anchorX, anchorY
|
|
953
|
+
const vw = window.innerWidth || 1024;
|
|
954
|
+
const vh = window.innerHeight || 768;
|
|
955
|
+
const popWidth = 348;
|
|
956
|
+
const popHeight = 310;
|
|
957
|
+
const left = Math.max(12, Math.min(this.anchorX + 14, vw - popWidth - 12));
|
|
958
|
+
const top =
|
|
959
|
+
this.anchorY + popHeight + 20 > vh
|
|
960
|
+
? Math.max(12, this.anchorY - popHeight - 12)
|
|
961
|
+
: Math.max(12, this.anchorY + 14);
|
|
962
|
+
|
|
963
|
+
this.popoverEl.style.left = `${left}px`;
|
|
964
|
+
this.popoverEl.style.top = `${top}px`;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
private handleKeyDown(event: KeyboardEvent): void {
|
|
968
|
+
if (!this.active) return;
|
|
969
|
+
|
|
970
|
+
if (event.key === "Escape") {
|
|
971
|
+
event.preventDefault();
|
|
972
|
+
event.stopPropagation();
|
|
973
|
+
if (this.anchored) {
|
|
974
|
+
this.closeLayerPopover();
|
|
975
|
+
} else {
|
|
976
|
+
this.stop();
|
|
977
|
+
}
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
if (event.key === "ArrowUp" || event.key === "[") {
|
|
982
|
+
event.preventDefault();
|
|
983
|
+
event.stopPropagation();
|
|
984
|
+
this.cycleDepth(1);
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
if (event.key === "ArrowDown" || event.key === "]") {
|
|
989
|
+
event.preventDefault();
|
|
990
|
+
event.stopPropagation();
|
|
991
|
+
this.cycleDepth(-1);
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
if (event.key === "Enter") {
|
|
996
|
+
const candidate = this.getActiveCandidate();
|
|
997
|
+
if (candidate) {
|
|
998
|
+
event.preventDefault();
|
|
999
|
+
event.stopPropagation();
|
|
1000
|
+
this.selectElementDirectly(candidate.element);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
}
|