kapi-ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/comments.js +363 -0
- package/dist/constants.js +1 -0
- package/dist/hover-panel.js +118 -0
- package/dist/inspector.js +168 -0
- package/dist/location-transform.js +47 -0
- package/dist/overlay.js +378 -0
- package/dist/server.js +118 -0
- package/dist/setup.js +46 -0
- package/dist/socket.js +36 -0
- package/dist/types.js +1 -0
- package/dist/utils.js +44 -0
- package/dist/vite-plugin.js +44 -0
- package/package.json +46 -0
package/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# kapi
|
package/dist/comments.js
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { lockHighlightOn, unlockHighlight, getSourceLocation } from './inspector.js';
|
|
2
|
+
const TAG = 'kapi-comments';
|
|
3
|
+
const STORAGE_KEY = `kapi-comments:${location.pathname}`;
|
|
4
|
+
const MARKER_COLOR = '34, 197, 94'; // green-500, matches the hover highlight
|
|
5
|
+
const MARKER_SIZE = 22;
|
|
6
|
+
const MARKER_RADIUS = MARKER_SIZE / 2;
|
|
7
|
+
const PANEL_WIDTH = 240; // shared fixed width for both the composer and the submitted tooltip
|
|
8
|
+
// viewBox is cropped to the path's actual ink bounding box (0,0 to ~4.08,4.08),
|
|
9
|
+
// not the original 0 0 5 5 -- the artwork isn't centered in that stated box,
|
|
10
|
+
// so a naive flex-centered render looks visibly off.
|
|
11
|
+
const ARROW_SVG = `<svg viewBox="0 0 4.08228 4.08228" width="10" height="10" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M4.08228 1.9241L3.65301 2.33273L2.35468 1.10883L2.35468 4.08228L1.72761 4.08228L1.72761 1.10883L0.431375 2.33273L-9.43368e-08 1.9241L2.04114 -6.76765e-06L4.08228 1.9241Z" fill="#1E1E1F"/></svg>`;
|
|
12
|
+
const STYLES = `
|
|
13
|
+
:host {
|
|
14
|
+
all: initial;
|
|
15
|
+
color-scheme: dark;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.kapi-comment {
|
|
19
|
+
position: fixed;
|
|
20
|
+
top: 0;
|
|
21
|
+
left: 0;
|
|
22
|
+
z-index: 2147483647;
|
|
23
|
+
display: flex;
|
|
24
|
+
align-items: center;
|
|
25
|
+
gap: 8px;
|
|
26
|
+
pointer-events: none;
|
|
27
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.kapi-comment-marker {
|
|
31
|
+
display: flex;
|
|
32
|
+
align-items: center;
|
|
33
|
+
justify-content: center;
|
|
34
|
+
box-sizing: border-box;
|
|
35
|
+
width: ${MARKER_SIZE}px;
|
|
36
|
+
height: ${MARKER_SIZE}px;
|
|
37
|
+
border-radius: 50%;
|
|
38
|
+
background: rgb(${MARKER_COLOR});
|
|
39
|
+
color: #fff;
|
|
40
|
+
font-size: 11px;
|
|
41
|
+
font-weight: 600;
|
|
42
|
+
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
|
|
43
|
+
pointer-events: auto;
|
|
44
|
+
cursor: default;
|
|
45
|
+
flex: none;
|
|
46
|
+
user-select: none;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.kapi-comment-tooltip,
|
|
50
|
+
.kapi-comment-composer {
|
|
51
|
+
display: none;
|
|
52
|
+
position: absolute;
|
|
53
|
+
left: ${MARKER_SIZE + 8}px;
|
|
54
|
+
top: 50%;
|
|
55
|
+
transform: translateY(-50%);
|
|
56
|
+
width: ${PANEL_WIDTH}px;
|
|
57
|
+
box-sizing: border-box;
|
|
58
|
+
border-radius: 12px;
|
|
59
|
+
background: #1e1e1f;
|
|
60
|
+
box-shadow:
|
|
61
|
+
inset 0 0 0 1px rgba(255, 255, 255, 0.14),
|
|
62
|
+
0 2px 4px rgba(0, 0, 0, 0.2),
|
|
63
|
+
0 8px 16px rgba(0, 0, 0, 0.2);
|
|
64
|
+
pointer-events: auto;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.kapi-comment-tooltip {
|
|
68
|
+
flex-direction: column;
|
|
69
|
+
gap: 2px;
|
|
70
|
+
padding: 8px 14px;
|
|
71
|
+
color: #fff;
|
|
72
|
+
font-size: 13px;
|
|
73
|
+
line-height: 1.3;
|
|
74
|
+
white-space: normal;
|
|
75
|
+
overflow-wrap: break-word;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.kapi-comment.kapi-hovering .kapi-comment-tooltip {
|
|
79
|
+
display: flex;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.kapi-comment-tooltip-source {
|
|
83
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
84
|
+
font-size: 11px;
|
|
85
|
+
font-weight: 600;
|
|
86
|
+
color: rgb(${MARKER_COLOR});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.kapi-comment-composer {
|
|
90
|
+
display: flex;
|
|
91
|
+
align-items: flex-end;
|
|
92
|
+
gap: 8px;
|
|
93
|
+
padding: 6px 6px 6px 14px;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.kapi-comment-input {
|
|
97
|
+
all: unset;
|
|
98
|
+
display: block;
|
|
99
|
+
flex: 1 1 auto;
|
|
100
|
+
min-width: 0;
|
|
101
|
+
max-height: 120px;
|
|
102
|
+
overflow-y: auto;
|
|
103
|
+
resize: none;
|
|
104
|
+
color: #fff;
|
|
105
|
+
font-family: inherit;
|
|
106
|
+
font-size: 13px;
|
|
107
|
+
line-height: 1.3;
|
|
108
|
+
padding: 5px 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.kapi-comment-input::placeholder {
|
|
112
|
+
color: rgba(255, 255, 255, 0.4);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.kapi-comment-send {
|
|
116
|
+
all: unset;
|
|
117
|
+
display: flex;
|
|
118
|
+
align-items: center;
|
|
119
|
+
justify-content: center;
|
|
120
|
+
box-sizing: border-box;
|
|
121
|
+
width: 24px;
|
|
122
|
+
height: 24px;
|
|
123
|
+
border-radius: 8px;
|
|
124
|
+
background: #fff;
|
|
125
|
+
cursor: pointer;
|
|
126
|
+
flex: none;
|
|
127
|
+
}
|
|
128
|
+
`;
|
|
129
|
+
let root = null;
|
|
130
|
+
let comments = [];
|
|
131
|
+
let draft = null;
|
|
132
|
+
// Builds a positional selector (nth-child chain from <body>) so a comment's
|
|
133
|
+
// element can be re-found across page reloads without relying on id/class.
|
|
134
|
+
function buildUniqueSelector(el) {
|
|
135
|
+
const parts = [];
|
|
136
|
+
let node = el;
|
|
137
|
+
while (node && node !== document.body) {
|
|
138
|
+
const parentEl = node.parentElement;
|
|
139
|
+
if (!parentEl)
|
|
140
|
+
break;
|
|
141
|
+
const index = Array.from(parentEl.children).indexOf(node) + 1;
|
|
142
|
+
parts.unshift(`${node.tagName.toLowerCase()}:nth-child(${index})`);
|
|
143
|
+
node = parentEl;
|
|
144
|
+
}
|
|
145
|
+
parts.unshift('body');
|
|
146
|
+
return parts.join(' > ');
|
|
147
|
+
}
|
|
148
|
+
function saveToStorage() {
|
|
149
|
+
const data = comments.map((c) => ({
|
|
150
|
+
id: c.id,
|
|
151
|
+
selector: buildUniqueSelector(c.el),
|
|
152
|
+
ratioX: c.ratioX,
|
|
153
|
+
ratioY: c.ratioY,
|
|
154
|
+
text: c.text,
|
|
155
|
+
source: c.source,
|
|
156
|
+
}));
|
|
157
|
+
try {
|
|
158
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
/* ignore (storage disabled/full) */
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function loadFromStorage() {
|
|
165
|
+
let data;
|
|
166
|
+
try {
|
|
167
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
168
|
+
if (!raw)
|
|
169
|
+
return;
|
|
170
|
+
data = JSON.parse(raw);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return; // ignore corrupt/inaccessible storage
|
|
174
|
+
}
|
|
175
|
+
for (const item of data) {
|
|
176
|
+
const el = document.querySelector(item.selector);
|
|
177
|
+
if (!el)
|
|
178
|
+
continue; // page structure changed since this was saved; skip it
|
|
179
|
+
comments.push({ id: item.id, el, ratioX: item.ratioX, ratioY: item.ratioY, text: item.text, source: item.source });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function ensureRoot() {
|
|
183
|
+
if (root)
|
|
184
|
+
return root;
|
|
185
|
+
const host = document.createElement(TAG);
|
|
186
|
+
root = host.attachShadow({ mode: 'open' });
|
|
187
|
+
const style = document.createElement('style');
|
|
188
|
+
style.textContent = STYLES;
|
|
189
|
+
root.appendChild(style);
|
|
190
|
+
document.body.appendChild(host);
|
|
191
|
+
return root;
|
|
192
|
+
}
|
|
193
|
+
function clamp(value, min, max) {
|
|
194
|
+
return Math.min(Math.max(value, min), max);
|
|
195
|
+
}
|
|
196
|
+
function position(target, wrapper) {
|
|
197
|
+
const rect = target.el.getBoundingClientRect();
|
|
198
|
+
const x = rect.left + target.ratioX * rect.width;
|
|
199
|
+
const y = rect.top + target.ratioY * rect.height;
|
|
200
|
+
wrapper.style.transform = `translate(${x - MARKER_RADIUS}px, ${y - MARKER_RADIUS}px)`;
|
|
201
|
+
}
|
|
202
|
+
function renderMarker(number, target, text, source) {
|
|
203
|
+
const wrapper = document.createElement('div');
|
|
204
|
+
wrapper.className = 'kapi-comment';
|
|
205
|
+
const marker = document.createElement('div');
|
|
206
|
+
marker.className = 'kapi-comment-marker';
|
|
207
|
+
marker.textContent = String(number);
|
|
208
|
+
const tooltip = document.createElement('div');
|
|
209
|
+
tooltip.className = 'kapi-comment-tooltip';
|
|
210
|
+
if (source) {
|
|
211
|
+
const sourceEl = document.createElement('div');
|
|
212
|
+
sourceEl.className = 'kapi-comment-tooltip-source';
|
|
213
|
+
sourceEl.textContent = `${source.file}:${source.line}:${source.column}`;
|
|
214
|
+
tooltip.appendChild(sourceEl);
|
|
215
|
+
}
|
|
216
|
+
const textEl = document.createElement('div');
|
|
217
|
+
textEl.textContent = text;
|
|
218
|
+
tooltip.appendChild(textEl);
|
|
219
|
+
marker.addEventListener('mouseenter', () => wrapper.classList.add('kapi-hovering'));
|
|
220
|
+
marker.addEventListener('mouseleave', () => wrapper.classList.remove('kapi-hovering'));
|
|
221
|
+
wrapper.append(marker, tooltip);
|
|
222
|
+
position(target, wrapper);
|
|
223
|
+
return wrapper;
|
|
224
|
+
}
|
|
225
|
+
function renderComposer(number, target) {
|
|
226
|
+
const wrapper = document.createElement('div');
|
|
227
|
+
wrapper.className = 'kapi-comment';
|
|
228
|
+
const marker = document.createElement('div');
|
|
229
|
+
marker.className = 'kapi-comment-marker';
|
|
230
|
+
marker.textContent = String(number);
|
|
231
|
+
const composer = document.createElement('div');
|
|
232
|
+
composer.className = 'kapi-comment-composer';
|
|
233
|
+
const input = document.createElement('textarea');
|
|
234
|
+
input.className = 'kapi-comment-input';
|
|
235
|
+
input.placeholder = 'Add a comment';
|
|
236
|
+
input.rows = 1;
|
|
237
|
+
const autoGrow = () => {
|
|
238
|
+
input.style.height = 'auto';
|
|
239
|
+
input.style.height = `${input.scrollHeight}px`;
|
|
240
|
+
};
|
|
241
|
+
input.addEventListener('input', autoGrow);
|
|
242
|
+
const sendBtn = document.createElement('button');
|
|
243
|
+
sendBtn.type = 'button';
|
|
244
|
+
sendBtn.className = 'kapi-comment-send';
|
|
245
|
+
sendBtn.setAttribute('aria-label', 'Submit comment');
|
|
246
|
+
sendBtn.innerHTML = ARROW_SVG;
|
|
247
|
+
const submit = () => submitDraft(input.value);
|
|
248
|
+
sendBtn.addEventListener('click', submit);
|
|
249
|
+
input.addEventListener('keydown', (e) => {
|
|
250
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
251
|
+
e.preventDefault();
|
|
252
|
+
submit();
|
|
253
|
+
}
|
|
254
|
+
if (e.key === 'Escape')
|
|
255
|
+
cancelDraft();
|
|
256
|
+
});
|
|
257
|
+
composer.append(input, sendBtn);
|
|
258
|
+
wrapper.append(marker, composer);
|
|
259
|
+
position(target, wrapper);
|
|
260
|
+
queueMicrotask(() => {
|
|
261
|
+
input.focus();
|
|
262
|
+
autoGrow();
|
|
263
|
+
});
|
|
264
|
+
return wrapper;
|
|
265
|
+
}
|
|
266
|
+
function render() {
|
|
267
|
+
const r = ensureRoot();
|
|
268
|
+
r.querySelectorAll('.kapi-comment').forEach((n) => n.remove());
|
|
269
|
+
for (const entry of comments) {
|
|
270
|
+
r.appendChild(renderMarker(entry.id, entry, entry.text, entry.source));
|
|
271
|
+
}
|
|
272
|
+
if (draft) {
|
|
273
|
+
r.appendChild(renderComposer(comments.length + 1, draft));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
function repositionAll() {
|
|
277
|
+
if (!root)
|
|
278
|
+
return;
|
|
279
|
+
const wrappers = root.querySelectorAll('.kapi-comment');
|
|
280
|
+
const targets = [...comments];
|
|
281
|
+
if (draft)
|
|
282
|
+
targets.push(draft);
|
|
283
|
+
wrappers.forEach((wrapper, i) => {
|
|
284
|
+
const target = targets[i];
|
|
285
|
+
if (target)
|
|
286
|
+
position(target, wrapper);
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
window.addEventListener('resize', repositionAll);
|
|
290
|
+
window.addEventListener('scroll', repositionAll, true);
|
|
291
|
+
document.addEventListener('keydown', (e) => {
|
|
292
|
+
if (e.key === 'Escape' && draft)
|
|
293
|
+
cancelDraft();
|
|
294
|
+
}, true);
|
|
295
|
+
function submitDraft(rawText) {
|
|
296
|
+
if (!draft)
|
|
297
|
+
return;
|
|
298
|
+
const text = rawText.trim();
|
|
299
|
+
if (!text) {
|
|
300
|
+
cancelDraft();
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
comments.push({
|
|
304
|
+
id: comments.length + 1,
|
|
305
|
+
el: draft.el,
|
|
306
|
+
ratioX: draft.ratioX,
|
|
307
|
+
ratioY: draft.ratioY,
|
|
308
|
+
text,
|
|
309
|
+
source: getSourceLocation(draft.el),
|
|
310
|
+
});
|
|
311
|
+
draft = null;
|
|
312
|
+
unlockHighlight();
|
|
313
|
+
saveToStorage();
|
|
314
|
+
render();
|
|
315
|
+
}
|
|
316
|
+
function cancelDraft() {
|
|
317
|
+
draft = null;
|
|
318
|
+
unlockHighlight();
|
|
319
|
+
render();
|
|
320
|
+
}
|
|
321
|
+
export function cancelOpenDraft() {
|
|
322
|
+
if (draft)
|
|
323
|
+
cancelDraft();
|
|
324
|
+
}
|
|
325
|
+
export function buildCommentsPrompt() {
|
|
326
|
+
if (comments.length === 0)
|
|
327
|
+
return null;
|
|
328
|
+
const lines = comments.map((c) => {
|
|
329
|
+
const location = c.source ? `${c.source.file}:${c.source.line}:${c.source.column}` : buildUniqueSelector(c.el);
|
|
330
|
+
return `${c.id}. [${location}] ${c.text}`;
|
|
331
|
+
});
|
|
332
|
+
return [
|
|
333
|
+
'Address each of the following review comments left on specific elements in this app:',
|
|
334
|
+
'',
|
|
335
|
+
...lines,
|
|
336
|
+
].join('\n');
|
|
337
|
+
}
|
|
338
|
+
export function clearAllComments() {
|
|
339
|
+
comments = [];
|
|
340
|
+
if (draft) {
|
|
341
|
+
draft = null;
|
|
342
|
+
unlockHighlight();
|
|
343
|
+
}
|
|
344
|
+
try {
|
|
345
|
+
localStorage.removeItem(STORAGE_KEY);
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
/* ignore (storage disabled) */
|
|
349
|
+
}
|
|
350
|
+
render();
|
|
351
|
+
}
|
|
352
|
+
export function beginComment(el, clientX, clientY) {
|
|
353
|
+
if (draft)
|
|
354
|
+
return; // a draft is already open; must be sent (or Escaped) before starting another
|
|
355
|
+
const rect = el.getBoundingClientRect();
|
|
356
|
+
const ratioX = rect.width > 0 ? clamp((clientX - rect.left) / rect.width, 0, 1) : 0.5;
|
|
357
|
+
const ratioY = rect.height > 0 ? clamp((clientY - rect.top) / rect.height, 0, 1) : 0.5;
|
|
358
|
+
draft = { el, ratioX, ratioY };
|
|
359
|
+
lockHighlightOn(el);
|
|
360
|
+
render();
|
|
361
|
+
}
|
|
362
|
+
loadFromStorage();
|
|
363
|
+
render();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const KAPI_SERVER_PORT = 6767;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
const PANEL_TAG = 'kapi-hover-panel';
|
|
2
|
+
const STYLES = `
|
|
3
|
+
:host {
|
|
4
|
+
all: initial;
|
|
5
|
+
position: fixed;
|
|
6
|
+
top: 20px;
|
|
7
|
+
right: 20px;
|
|
8
|
+
z-index: 2147483647;
|
|
9
|
+
color-scheme: dark;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.kapi-hover-panel {
|
|
13
|
+
display: none;
|
|
14
|
+
max-width: 360px;
|
|
15
|
+
box-sizing: border-box;
|
|
16
|
+
padding: 8px 12px;
|
|
17
|
+
border-radius: 10px;
|
|
18
|
+
background: #1e1e1f;
|
|
19
|
+
box-shadow:
|
|
20
|
+
inset 0 0 0 1px rgba(255, 255, 255, 0.14),
|
|
21
|
+
0 2px 4px rgba(0, 0, 0, 0.2),
|
|
22
|
+
0 8px 16px rgba(0, 0, 0, 0.2);
|
|
23
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
24
|
+
font-size: 12px;
|
|
25
|
+
line-height: 1.5;
|
|
26
|
+
color: rgba(255, 255, 255, 0.75);
|
|
27
|
+
word-break: break-word;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.kapi-hover-panel.kapi-visible {
|
|
31
|
+
display: block;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.kapi-hover-panel b {
|
|
35
|
+
color: rgb(74, 222, 128);
|
|
36
|
+
font-weight: 600;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.kapi-hover-panel-source {
|
|
40
|
+
color: rgb(74, 222, 128);
|
|
41
|
+
font-weight: 600;
|
|
42
|
+
margin-bottom: 2px;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.kapi-hover-panel-selector {
|
|
46
|
+
color: rgba(255, 255, 255, 0.5);
|
|
47
|
+
font-size: 11px;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.kapi-hover-panel-status {
|
|
51
|
+
display: flex;
|
|
52
|
+
align-items: center;
|
|
53
|
+
gap: 6px;
|
|
54
|
+
color: rgba(255, 255, 255, 0.85);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.kapi-hover-panel-status-dot {
|
|
58
|
+
width: 6px;
|
|
59
|
+
height: 6px;
|
|
60
|
+
border-radius: 50%;
|
|
61
|
+
background: rgb(74, 222, 128);
|
|
62
|
+
flex: none;
|
|
63
|
+
animation: kapi-pulse 1s ease-in-out infinite;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@keyframes kapi-pulse {
|
|
67
|
+
0%, 100% { opacity: 1; }
|
|
68
|
+
50% { opacity: 0.3; }
|
|
69
|
+
}
|
|
70
|
+
`;
|
|
71
|
+
let host = null;
|
|
72
|
+
let panel = null;
|
|
73
|
+
function ensurePanel() {
|
|
74
|
+
if (panel)
|
|
75
|
+
return panel;
|
|
76
|
+
host = document.createElement(PANEL_TAG);
|
|
77
|
+
const root = host.attachShadow({ mode: 'open' });
|
|
78
|
+
const style = document.createElement('style');
|
|
79
|
+
style.textContent = STYLES;
|
|
80
|
+
root.appendChild(style);
|
|
81
|
+
panel = document.createElement('div');
|
|
82
|
+
panel.className = 'kapi-hover-panel';
|
|
83
|
+
root.appendChild(panel);
|
|
84
|
+
document.body.appendChild(host);
|
|
85
|
+
return panel;
|
|
86
|
+
}
|
|
87
|
+
export function updateHoverPanel(location) {
|
|
88
|
+
const el = ensurePanel();
|
|
89
|
+
if (!location) {
|
|
90
|
+
el.classList.remove('kapi-visible');
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
el.replaceChildren();
|
|
94
|
+
if (location.source) {
|
|
95
|
+
const sourceEl = document.createElement('div');
|
|
96
|
+
sourceEl.className = 'kapi-hover-panel-source';
|
|
97
|
+
sourceEl.textContent = `${location.source.file}:${location.source.line}:${location.source.column}`;
|
|
98
|
+
el.appendChild(sourceEl);
|
|
99
|
+
}
|
|
100
|
+
const selectorEl = document.createElement('div');
|
|
101
|
+
selectorEl.className = 'kapi-hover-panel-selector';
|
|
102
|
+
selectorEl.textContent = location.selector;
|
|
103
|
+
el.appendChild(selectorEl);
|
|
104
|
+
el.classList.add('kapi-visible');
|
|
105
|
+
}
|
|
106
|
+
export function showProcessingStatus(status) {
|
|
107
|
+
const el = ensurePanel();
|
|
108
|
+
el.replaceChildren();
|
|
109
|
+
const row = document.createElement('div');
|
|
110
|
+
row.className = 'kapi-hover-panel-status';
|
|
111
|
+
const dot = document.createElement('span');
|
|
112
|
+
dot.className = 'kapi-hover-panel-status-dot';
|
|
113
|
+
const text = document.createElement('span');
|
|
114
|
+
text.textContent = status;
|
|
115
|
+
row.append(dot, text);
|
|
116
|
+
el.appendChild(row);
|
|
117
|
+
el.classList.add('kapi-visible');
|
|
118
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
const HIGHLIGHT_COLOR = '34, 197, 94'; // green-500, as an rgb triplet for reuse in rgba()
|
|
2
|
+
const IGNORE_SELECTOR = 'kapi-overlay, kapi-hover-panel, kapi-comments';
|
|
3
|
+
let highlightEl = null;
|
|
4
|
+
let blockerEl = null;
|
|
5
|
+
let hoveredEl = null;
|
|
6
|
+
let active = false;
|
|
7
|
+
let locked = false;
|
|
8
|
+
let disabled = false;
|
|
9
|
+
let onHover = null;
|
|
10
|
+
let onElementClick = null;
|
|
11
|
+
function ensureHighlightEl() {
|
|
12
|
+
if (highlightEl)
|
|
13
|
+
return highlightEl;
|
|
14
|
+
const el = document.createElement('div');
|
|
15
|
+
el.style.cssText = `
|
|
16
|
+
position: fixed;
|
|
17
|
+
top: 0;
|
|
18
|
+
left: 0;
|
|
19
|
+
pointer-events: none;
|
|
20
|
+
box-sizing: border-box;
|
|
21
|
+
background: rgba(${HIGHLIGHT_COLOR}, 0.2);
|
|
22
|
+
border: 2px solid rgb(${HIGHLIGHT_COLOR});
|
|
23
|
+
z-index: 2147483646;
|
|
24
|
+
display: none;
|
|
25
|
+
`;
|
|
26
|
+
document.body.appendChild(el);
|
|
27
|
+
highlightEl = el;
|
|
28
|
+
return el;
|
|
29
|
+
}
|
|
30
|
+
function handleBlockerClick(e) {
|
|
31
|
+
const stack = document.elementsFromPoint(e.clientX, e.clientY);
|
|
32
|
+
const el = stack.find(isInspectable) ?? null;
|
|
33
|
+
if (!el)
|
|
34
|
+
return;
|
|
35
|
+
onElementClick?.(el, e.clientX, e.clientY);
|
|
36
|
+
}
|
|
37
|
+
function ensureBlockerEl() {
|
|
38
|
+
if (blockerEl)
|
|
39
|
+
return blockerEl;
|
|
40
|
+
const el = document.createElement('div');
|
|
41
|
+
el.style.cssText = `
|
|
42
|
+
position: fixed;
|
|
43
|
+
inset: 0;
|
|
44
|
+
pointer-events: auto;
|
|
45
|
+
background: transparent;
|
|
46
|
+
z-index: 2147483645;
|
|
47
|
+
display: none;
|
|
48
|
+
`;
|
|
49
|
+
el.addEventListener('click', handleBlockerClick);
|
|
50
|
+
document.body.appendChild(el);
|
|
51
|
+
blockerEl = el;
|
|
52
|
+
return el;
|
|
53
|
+
}
|
|
54
|
+
function isInspectable(el) {
|
|
55
|
+
if (!el)
|
|
56
|
+
return false;
|
|
57
|
+
if (el === blockerEl)
|
|
58
|
+
return false;
|
|
59
|
+
if (el.closest(IGNORE_SELECTOR))
|
|
60
|
+
return false;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
function buildSelectorPath(el) {
|
|
64
|
+
const parts = [];
|
|
65
|
+
let node = el;
|
|
66
|
+
while (node && node !== document.body && node !== document.documentElement) {
|
|
67
|
+
let part = node.tagName.toLowerCase();
|
|
68
|
+
if (node.id)
|
|
69
|
+
part += `#${node.id}`;
|
|
70
|
+
else if (node.classList.length > 0)
|
|
71
|
+
part += `.${node.classList[0]}`;
|
|
72
|
+
parts.unshift(part);
|
|
73
|
+
node = node.parentElement;
|
|
74
|
+
}
|
|
75
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
76
|
+
}
|
|
77
|
+
export function getSourceLocation(el) {
|
|
78
|
+
const host = el.closest('[data-kapi-loc]');
|
|
79
|
+
if (!host)
|
|
80
|
+
return null;
|
|
81
|
+
const raw = host.getAttribute('data-kapi-loc');
|
|
82
|
+
const lastColon = raw.lastIndexOf(':');
|
|
83
|
+
const secondLastColon = raw.lastIndexOf(':', lastColon - 1);
|
|
84
|
+
if (lastColon === -1 || secondLastColon === -1)
|
|
85
|
+
return null;
|
|
86
|
+
return {
|
|
87
|
+
file: raw.slice(0, secondLastColon),
|
|
88
|
+
line: Number(raw.slice(secondLastColon + 1, lastColon)),
|
|
89
|
+
column: Number(raw.slice(lastColon + 1)),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
export function describeElement(el) {
|
|
93
|
+
return {
|
|
94
|
+
tag: el.tagName.toLowerCase(),
|
|
95
|
+
id: el.id || null,
|
|
96
|
+
classes: [...el.classList],
|
|
97
|
+
selector: buildSelectorPath(el),
|
|
98
|
+
source: getSourceLocation(el),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function paintHighlight(el) {
|
|
102
|
+
const box = ensureHighlightEl();
|
|
103
|
+
const rect = el.getBoundingClientRect();
|
|
104
|
+
box.style.display = 'block';
|
|
105
|
+
box.style.transform = `translate(${rect.left}px, ${rect.top}px)`;
|
|
106
|
+
box.style.width = `${rect.width}px`;
|
|
107
|
+
box.style.height = `${rect.height}px`;
|
|
108
|
+
}
|
|
109
|
+
function clearHighlight() {
|
|
110
|
+
if (highlightEl)
|
|
111
|
+
highlightEl.style.display = 'none';
|
|
112
|
+
hoveredEl = null;
|
|
113
|
+
onHover?.(null);
|
|
114
|
+
}
|
|
115
|
+
function handlePointerMove(e) {
|
|
116
|
+
if (locked)
|
|
117
|
+
return;
|
|
118
|
+
const stack = document.elementsFromPoint(e.clientX, e.clientY);
|
|
119
|
+
const el = stack.find(isInspectable) ?? null;
|
|
120
|
+
if (!el) {
|
|
121
|
+
clearHighlight();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (el === hoveredEl)
|
|
125
|
+
return;
|
|
126
|
+
hoveredEl = el;
|
|
127
|
+
paintHighlight(el);
|
|
128
|
+
onHover?.(el);
|
|
129
|
+
}
|
|
130
|
+
export function setOnHover(callback) {
|
|
131
|
+
onHover = callback;
|
|
132
|
+
}
|
|
133
|
+
export function lockHighlightOn(el) {
|
|
134
|
+
locked = true;
|
|
135
|
+
hoveredEl = el;
|
|
136
|
+
paintHighlight(el);
|
|
137
|
+
onHover?.(el);
|
|
138
|
+
}
|
|
139
|
+
export function unlockHighlight() {
|
|
140
|
+
locked = false;
|
|
141
|
+
}
|
|
142
|
+
export function setOnElementClick(callback) {
|
|
143
|
+
onElementClick = callback;
|
|
144
|
+
}
|
|
145
|
+
export function startInspecting() {
|
|
146
|
+
if (active || disabled)
|
|
147
|
+
return;
|
|
148
|
+
active = true;
|
|
149
|
+
document.addEventListener('pointermove', handlePointerMove, true);
|
|
150
|
+
ensureBlockerEl().style.display = 'block';
|
|
151
|
+
}
|
|
152
|
+
export function stopInspecting() {
|
|
153
|
+
if (!active)
|
|
154
|
+
return;
|
|
155
|
+
active = false;
|
|
156
|
+
document.removeEventListener('pointermove', handlePointerMove, true);
|
|
157
|
+
clearHighlight();
|
|
158
|
+
if (blockerEl)
|
|
159
|
+
blockerEl.style.display = 'none';
|
|
160
|
+
}
|
|
161
|
+
// Forcibly disables inspecting regardless of the bar's expand/collapse state,
|
|
162
|
+
// and blocks it from being re-enabled until re-allowed (e.g. while Claude is
|
|
163
|
+
// processing submitted comments).
|
|
164
|
+
export function setDisabled(value) {
|
|
165
|
+
disabled = value;
|
|
166
|
+
if (disabled)
|
|
167
|
+
stopInspecting();
|
|
168
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Matches an opening HTML/Vue tag, tolerating quoted attribute values that
|
|
2
|
+
// contain `>` (e.g. `v-if="a > b"`) so we don't mistake them for the tag end.
|
|
3
|
+
const TAG_RE = /<([a-zA-Z][\w-]*)(?:\s+[^"'>]*(?:"[^"]*"|'[^']*')?)*\s*\/?>/g;
|
|
4
|
+
function computeLineCol(code, index) {
|
|
5
|
+
let line = 1;
|
|
6
|
+
let lastNewlineIndex = -1;
|
|
7
|
+
for (let i = 0; i < index; i++) {
|
|
8
|
+
if (code.charCodeAt(i) === 10 /* \n */) {
|
|
9
|
+
line++;
|
|
10
|
+
lastNewlineIndex = i;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return { line, column: index - lastNewlineIndex };
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Stamps every element inside a Vue SFC's <template> block with a
|
|
17
|
+
* `data-kapi-loc="relativeFile:line:column"` attribute, computed from the
|
|
18
|
+
* raw source text before @vitejs/plugin-vue compiles the template away.
|
|
19
|
+
*/
|
|
20
|
+
export function stampTemplateLocations(code, relativeFile) {
|
|
21
|
+
const templateOpenMatch = code.match(/<template\b[^>]*>/);
|
|
22
|
+
if (!templateOpenMatch || templateOpenMatch.index === undefined)
|
|
23
|
+
return code;
|
|
24
|
+
const templateContentStart = templateOpenMatch.index + templateOpenMatch[0].length;
|
|
25
|
+
const templateCloseIndex = code.lastIndexOf('</template>');
|
|
26
|
+
if (templateCloseIndex === -1 || templateCloseIndex <= templateContentStart)
|
|
27
|
+
return code;
|
|
28
|
+
let result = '';
|
|
29
|
+
let cursor = 0;
|
|
30
|
+
TAG_RE.lastIndex = templateContentStart;
|
|
31
|
+
let match;
|
|
32
|
+
while ((match = TAG_RE.exec(code))) {
|
|
33
|
+
if (match.index >= templateCloseIndex)
|
|
34
|
+
break;
|
|
35
|
+
const tagName = match[1];
|
|
36
|
+
const isRootTemplateTag = match.index === templateOpenMatch.index;
|
|
37
|
+
if (tagName.toLowerCase() === 'template' && isRootTemplateTag)
|
|
38
|
+
continue;
|
|
39
|
+
const { line, column } = computeLineCol(code, match.index);
|
|
40
|
+
const tagNameEnd = match.index + 1 + tagName.length;
|
|
41
|
+
const insertion = ` data-kapi-loc="${relativeFile}:${line}:${column}"`;
|
|
42
|
+
result += code.slice(cursor, tagNameEnd) + insertion;
|
|
43
|
+
cursor = tagNameEnd;
|
|
44
|
+
}
|
|
45
|
+
result += code.slice(cursor);
|
|
46
|
+
return result;
|
|
47
|
+
}
|