kapi-ui 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/browser/comments.js +531 -0
- package/dist/{hover-panel.js → browser/hover-panel.js} +63 -17
- package/dist/browser/icons.js +35 -0
- package/dist/browser/inspector.js +423 -0
- package/dist/browser/overlay.js +322 -0
- package/dist/browser/socket.js +38 -0
- package/dist/browser/styles/comments.css +246 -0
- package/dist/browser/styles/overlay.css +181 -0
- package/dist/browser/trace-record.js +33 -0
- package/dist/browser/types.js +4 -0
- package/dist/cli/setup.js +190 -0
- package/dist/cli/types.js +2 -0
- package/dist/{utils.js → cli/utils.js} +39 -11
- package/dist/node/claude-agent.js +185 -0
- package/dist/node/codex-agent.js +138 -0
- package/dist/node/nuxt-module.js +34 -0
- package/dist/node/types.js +4 -0
- package/dist/node/vite-plugin.js +213 -0
- package/package.json +12 -11
- package/dist/comments.js +0 -363
- package/dist/constants.js +0 -1
- package/dist/inspector.js +0 -168
- package/dist/location-transform.js +0 -47
- package/dist/nuxt-module.js +0 -21
- package/dist/overlay.js +0 -378
- package/dist/server.js +0 -118
- package/dist/setup.js +0 -65
- package/dist/socket.js +0 -36
- package/dist/types.js +0 -1
- package/dist/vite-plugin.js +0 -44
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 James Bermudo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
import { lockHighlightOn, unlockHighlight, clearHighlightIfNotInspecting, getSourceLocation, getComponentInfo, renderComponentBadge, isDisabled, clearSelection, isBoxSelectClick, lockWithoutHighlight, previewElements, clearPreview, } from './inspector.js';
|
|
2
|
+
import { ARROW_SVG, DELETE_SVG } from './icons.js';
|
|
3
|
+
import STYLES from './styles/comments.css?inline';
|
|
4
|
+
const TAG = 'kapi-comments';
|
|
5
|
+
const MARKER_SIZE = 22;
|
|
6
|
+
const MARKER_RADIUS = MARKER_SIZE / 2;
|
|
7
|
+
// Storage key and in-memory comments are scoped to the current page. In an
|
|
8
|
+
// SPA, navigating via the router doesn't reload this script, so both are
|
|
9
|
+
// re-derived from `location.pathname` on every navigation (see
|
|
10
|
+
// watchForNavigation below) rather than fixed once at module load.
|
|
11
|
+
let storageKey = `kapi-comments:${location.pathname}`;
|
|
12
|
+
let currentPathname = location.pathname;
|
|
13
|
+
let root = null;
|
|
14
|
+
let comments = [];
|
|
15
|
+
let draft = null;
|
|
16
|
+
// Builds a positional selector (nth-child chain from <body>) so a comment's
|
|
17
|
+
// element can be re-found across page reloads without relying on id/class.
|
|
18
|
+
function buildUniqueSelector(el) {
|
|
19
|
+
const parts = [];
|
|
20
|
+
let node = el;
|
|
21
|
+
while (node && node !== document.body) {
|
|
22
|
+
const parentEl = node.parentElement;
|
|
23
|
+
if (!parentEl)
|
|
24
|
+
break;
|
|
25
|
+
const index = Array.from(parentEl.children).indexOf(node) + 1;
|
|
26
|
+
parts.unshift(`${node.tagName.toLowerCase()}:nth-child(${index})`);
|
|
27
|
+
node = parentEl;
|
|
28
|
+
}
|
|
29
|
+
parts.unshift('body');
|
|
30
|
+
return parts.join(' > ');
|
|
31
|
+
}
|
|
32
|
+
function saveToStorage() {
|
|
33
|
+
const data = comments.map((c) => ({
|
|
34
|
+
id: c.id,
|
|
35
|
+
selector: buildUniqueSelector(c.el),
|
|
36
|
+
ratioX: c.ratioX,
|
|
37
|
+
ratioY: c.ratioY,
|
|
38
|
+
text: c.text,
|
|
39
|
+
source: c.source,
|
|
40
|
+
component: c.component,
|
|
41
|
+
targets: c.targets?.map((t) => ({
|
|
42
|
+
selector: buildUniqueSelector(t.el),
|
|
43
|
+
source: t.source,
|
|
44
|
+
component: t.component,
|
|
45
|
+
})),
|
|
46
|
+
}));
|
|
47
|
+
try {
|
|
48
|
+
localStorage.setItem(storageKey, JSON.stringify(data));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
/* ignore (storage disabled/full) */
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function loadFromStorage() {
|
|
55
|
+
let data;
|
|
56
|
+
try {
|
|
57
|
+
const raw = localStorage.getItem(storageKey);
|
|
58
|
+
if (!raw)
|
|
59
|
+
return;
|
|
60
|
+
data = JSON.parse(raw);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return; // ignore corrupt/inaccessible storage
|
|
64
|
+
}
|
|
65
|
+
for (const item of data) {
|
|
66
|
+
const el = document.querySelector(item.selector);
|
|
67
|
+
if (!el)
|
|
68
|
+
continue; // page structure changed since this was saved; skip it
|
|
69
|
+
let targets;
|
|
70
|
+
if (item.targets) {
|
|
71
|
+
targets = item.targets.reduce((acc, t) => {
|
|
72
|
+
const targetEl = document.querySelector(t.selector);
|
|
73
|
+
if (targetEl)
|
|
74
|
+
acc.push({ el: targetEl, source: t.source, component: t.component });
|
|
75
|
+
return acc;
|
|
76
|
+
}, []);
|
|
77
|
+
}
|
|
78
|
+
comments.push({
|
|
79
|
+
id: item.id,
|
|
80
|
+
el,
|
|
81
|
+
ratioX: item.ratioX,
|
|
82
|
+
ratioY: item.ratioY,
|
|
83
|
+
text: item.text,
|
|
84
|
+
source: item.source,
|
|
85
|
+
component: item.component,
|
|
86
|
+
targets,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function ensureRoot() {
|
|
91
|
+
if (root)
|
|
92
|
+
return root;
|
|
93
|
+
const host = document.createElement(TAG);
|
|
94
|
+
root = host.attachShadow({ mode: 'open' });
|
|
95
|
+
const style = document.createElement('style');
|
|
96
|
+
style.textContent = STYLES;
|
|
97
|
+
root.appendChild(style);
|
|
98
|
+
document.body.appendChild(host);
|
|
99
|
+
return root;
|
|
100
|
+
}
|
|
101
|
+
function clamp(value, min, max) {
|
|
102
|
+
return Math.min(Math.max(value, min), max);
|
|
103
|
+
}
|
|
104
|
+
function position(target, wrapper) {
|
|
105
|
+
const rect = target.el.getBoundingClientRect();
|
|
106
|
+
const x = rect.left + target.ratioX * rect.width;
|
|
107
|
+
const y = rect.top + target.ratioY * rect.height;
|
|
108
|
+
wrapper.style.setProperty('--kapi-x', `${x - MARKER_RADIUS}px`);
|
|
109
|
+
wrapper.style.setProperty('--kapi-y', `${y - MARKER_RADIUS}px`);
|
|
110
|
+
}
|
|
111
|
+
// Plays the exit animation on a wrapper, then removes it. The node keeps its
|
|
112
|
+
// `.kapi-comment` class (so its position vars still apply) but gains
|
|
113
|
+
// `.kapi-leaving`, which render()/repositionAll skip — so it animates out
|
|
114
|
+
// independently of the fresh nodes render() builds, then self-removes.
|
|
115
|
+
function animateOut(node) {
|
|
116
|
+
node.classList.add('kapi-leaving');
|
|
117
|
+
// Fallback: if animationend never fires (reduced motion, backgrounded tab,
|
|
118
|
+
// animation not run), remove anyway so leaving nodes can't accumulate.
|
|
119
|
+
let fallback;
|
|
120
|
+
const remove = () => {
|
|
121
|
+
clearTimeout(fallback);
|
|
122
|
+
node.removeEventListener('animationend', onEnd);
|
|
123
|
+
node.remove();
|
|
124
|
+
};
|
|
125
|
+
const onEnd = (e) => {
|
|
126
|
+
if (e.target !== node)
|
|
127
|
+
return;
|
|
128
|
+
remove();
|
|
129
|
+
};
|
|
130
|
+
node.addEventListener('animationend', onEnd);
|
|
131
|
+
fallback = setTimeout(remove, 250);
|
|
132
|
+
}
|
|
133
|
+
function renderMarker(entry, label) {
|
|
134
|
+
const wrapper = document.createElement('div');
|
|
135
|
+
wrapper.className = 'kapi-comment';
|
|
136
|
+
const marker = document.createElement('div');
|
|
137
|
+
marker.className = 'kapi-comment-marker';
|
|
138
|
+
marker.textContent = label;
|
|
139
|
+
const tooltip = document.createElement('div');
|
|
140
|
+
tooltip.className = 'kapi-comment-tooltip';
|
|
141
|
+
if (entry.component) {
|
|
142
|
+
tooltip.appendChild(renderComponentBadge(entry.component, 'kapi-comment-tooltip-component'));
|
|
143
|
+
}
|
|
144
|
+
if (entry.source) {
|
|
145
|
+
const sourceEl = document.createElement('div');
|
|
146
|
+
sourceEl.className = 'kapi-comment-tooltip-source';
|
|
147
|
+
sourceEl.textContent = `${entry.source.file}:${entry.source.line}:${entry.source.column}`;
|
|
148
|
+
tooltip.appendChild(sourceEl);
|
|
149
|
+
}
|
|
150
|
+
const textEl = document.createElement('div');
|
|
151
|
+
textEl.textContent = entry.text;
|
|
152
|
+
tooltip.appendChild(textEl);
|
|
153
|
+
if (entry.targets && entry.targets.length > 1) {
|
|
154
|
+
const countEl = document.createElement('div');
|
|
155
|
+
countEl.className = 'kapi-comment-tooltip-source';
|
|
156
|
+
countEl.textContent = `applies to ${entry.targets.length} elements`;
|
|
157
|
+
tooltip.appendChild(countEl);
|
|
158
|
+
}
|
|
159
|
+
marker.addEventListener('mouseenter', () => wrapper.classList.add('kapi-hovering'));
|
|
160
|
+
marker.addEventListener('mouseleave', () => wrapper.classList.remove('kapi-hovering'));
|
|
161
|
+
marker.addEventListener('click', () => beginEdit(entry));
|
|
162
|
+
wrapper.append(marker, tooltip);
|
|
163
|
+
position(entry, wrapper);
|
|
164
|
+
return wrapper;
|
|
165
|
+
}
|
|
166
|
+
function renderComposer(label, target, initialText = '') {
|
|
167
|
+
const wrapper = document.createElement('div');
|
|
168
|
+
wrapper.className = 'kapi-comment';
|
|
169
|
+
const marker = document.createElement('div');
|
|
170
|
+
marker.className = 'kapi-comment-marker kapi-comment-marker-enter';
|
|
171
|
+
marker.textContent = label;
|
|
172
|
+
const composer = document.createElement('div');
|
|
173
|
+
composer.className = 'kapi-comment-composer';
|
|
174
|
+
const input = document.createElement('textarea');
|
|
175
|
+
input.className = 'kapi-comment-input';
|
|
176
|
+
input.placeholder = 'Add a comment';
|
|
177
|
+
input.rows = 1;
|
|
178
|
+
input.value = initialText;
|
|
179
|
+
const autoGrow = () => {
|
|
180
|
+
input.style.height = 'auto';
|
|
181
|
+
input.style.height = `${input.scrollHeight}px`;
|
|
182
|
+
};
|
|
183
|
+
input.addEventListener('input', autoGrow);
|
|
184
|
+
const sendBtn = document.createElement('button');
|
|
185
|
+
sendBtn.type = 'button';
|
|
186
|
+
sendBtn.className = 'kapi-comment-send';
|
|
187
|
+
sendBtn.setAttribute('aria-label', 'Submit comment');
|
|
188
|
+
sendBtn.innerHTML = ARROW_SVG;
|
|
189
|
+
const submit = () => submitDraft(input.value);
|
|
190
|
+
sendBtn.addEventListener('click', submit);
|
|
191
|
+
input.addEventListener('keydown', (e) => {
|
|
192
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
193
|
+
e.preventDefault();
|
|
194
|
+
submit();
|
|
195
|
+
}
|
|
196
|
+
if (e.key === 'Escape')
|
|
197
|
+
cancelDraft();
|
|
198
|
+
});
|
|
199
|
+
const inputRow = document.createElement('div');
|
|
200
|
+
inputRow.className = 'kapi-comment-composer-input-row';
|
|
201
|
+
inputRow.appendChild(input);
|
|
202
|
+
const actionsRow = document.createElement('div');
|
|
203
|
+
actionsRow.className = 'kapi-comment-composer-actions';
|
|
204
|
+
if (initialText) {
|
|
205
|
+
const deleteBtn = document.createElement('button');
|
|
206
|
+
deleteBtn.type = 'button';
|
|
207
|
+
deleteBtn.className = 'kapi-comment-delete';
|
|
208
|
+
deleteBtn.setAttribute('aria-label', 'Delete comment');
|
|
209
|
+
deleteBtn.innerHTML = DELETE_SVG;
|
|
210
|
+
deleteBtn.addEventListener('click', () => {
|
|
211
|
+
if (draft?.id)
|
|
212
|
+
deleteComment(draft.id);
|
|
213
|
+
});
|
|
214
|
+
actionsRow.appendChild(deleteBtn);
|
|
215
|
+
}
|
|
216
|
+
actionsRow.appendChild(sendBtn);
|
|
217
|
+
composer.append(inputRow, actionsRow);
|
|
218
|
+
wrapper.append(marker, composer);
|
|
219
|
+
position(target, wrapper);
|
|
220
|
+
queueMicrotask(() => {
|
|
221
|
+
input.focus();
|
|
222
|
+
input.setSelectionRange(input.value.length, input.value.length);
|
|
223
|
+
autoGrow();
|
|
224
|
+
// Lock the composer's vertical position to its initial (single-line) height,
|
|
225
|
+
// centered on the marker, so later growth only extends it downward instead
|
|
226
|
+
// of continuously re-centering (which would grow it upward too).
|
|
227
|
+
composer.style.top = `${MARKER_RADIUS - composer.getBoundingClientRect().height / 2}px`;
|
|
228
|
+
});
|
|
229
|
+
return wrapper;
|
|
230
|
+
}
|
|
231
|
+
// Sum of comments stored on every page that iterates before this one, so
|
|
232
|
+
// marker labels continue the global numbering (same localStorage order as
|
|
233
|
+
// buildAllCommentsPrompt, so markers and the copied/sent prompt agree). If the
|
|
234
|
+
// current page has no key yet (no comments saved), every existing page counts —
|
|
235
|
+
// which matches: its key gets appended last on first save.
|
|
236
|
+
function globalOffset() {
|
|
237
|
+
let offset = 0;
|
|
238
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
239
|
+
const key = localStorage.key(i);
|
|
240
|
+
if (!key?.startsWith('kapi-comments:'))
|
|
241
|
+
continue;
|
|
242
|
+
if (key === storageKey)
|
|
243
|
+
break;
|
|
244
|
+
try {
|
|
245
|
+
const data = JSON.parse(localStorage.getItem(key) || '[]');
|
|
246
|
+
if (Array.isArray(data))
|
|
247
|
+
offset += data.length;
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
/* skip corrupt entry (also skipped by buildAllCommentsPrompt) */
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return offset;
|
|
254
|
+
}
|
|
255
|
+
function render() {
|
|
256
|
+
const r = ensureRoot();
|
|
257
|
+
r.querySelectorAll('.kapi-comment:not(.kapi-leaving)').forEach((n) => n.remove());
|
|
258
|
+
const offset = globalOffset();
|
|
259
|
+
comments.forEach((entry, index) => {
|
|
260
|
+
const label = String(offset + index + 1);
|
|
261
|
+
if (draft && draft.id === entry.id) {
|
|
262
|
+
r.appendChild(renderComposer(label, draft, draft.text));
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
r.appendChild(renderMarker(entry, label));
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
if (draft && draft.id === undefined) {
|
|
269
|
+
const label = draft.els && draft.els.length > 1 ? `${draft.els.length}` : String(offset + comments.length + 1);
|
|
270
|
+
r.appendChild(renderComposer(label, draft, draft.text));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
function repositionAll() {
|
|
274
|
+
if (!root)
|
|
275
|
+
return;
|
|
276
|
+
const wrappers = root.querySelectorAll('.kapi-comment:not(.kapi-leaving)');
|
|
277
|
+
const targets = [...comments];
|
|
278
|
+
if (draft)
|
|
279
|
+
targets.push(draft);
|
|
280
|
+
wrappers.forEach((wrapper, i) => {
|
|
281
|
+
const target = targets[i];
|
|
282
|
+
if (target)
|
|
283
|
+
position(target, wrapper);
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
window.addEventListener('resize', repositionAll);
|
|
287
|
+
window.addEventListener('scroll', repositionAll, true);
|
|
288
|
+
document.addEventListener('keydown', (e) => {
|
|
289
|
+
if (e.key === 'Escape' && draft)
|
|
290
|
+
cancelDraft();
|
|
291
|
+
}, true);
|
|
292
|
+
document.addEventListener('click', (e) => {
|
|
293
|
+
if (!draft || !root)
|
|
294
|
+
return;
|
|
295
|
+
// Let a shift-click, or the trailing click a drag-select release fires,
|
|
296
|
+
// fall through to the inspector's blocker instead of canceling — those
|
|
297
|
+
// are how a batch draft's element selection keeps growing while its
|
|
298
|
+
// composer is open.
|
|
299
|
+
if (e.shiftKey || isBoxSelectClick())
|
|
300
|
+
return;
|
|
301
|
+
const target = e.target;
|
|
302
|
+
const kapiHost = root.host;
|
|
303
|
+
if (!kapiHost.contains(target)) {
|
|
304
|
+
e.stopImmediatePropagation();
|
|
305
|
+
cancelDraft();
|
|
306
|
+
clearHighlightIfNotInspecting();
|
|
307
|
+
}
|
|
308
|
+
}, true);
|
|
309
|
+
function submitDraft(rawText) {
|
|
310
|
+
if (!draft)
|
|
311
|
+
return;
|
|
312
|
+
const text = rawText.trim();
|
|
313
|
+
if (!text) {
|
|
314
|
+
cancelDraft();
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (draft.els) {
|
|
318
|
+
const targets = draft.els.map((el) => ({
|
|
319
|
+
el,
|
|
320
|
+
source: getSourceLocation(el),
|
|
321
|
+
component: getComponentInfo(el),
|
|
322
|
+
}));
|
|
323
|
+
comments.push({
|
|
324
|
+
id: comments.length + 1,
|
|
325
|
+
el: draft.el,
|
|
326
|
+
ratioX: 0.5,
|
|
327
|
+
ratioY: 0.5,
|
|
328
|
+
text,
|
|
329
|
+
source: targets[0]?.source ?? null,
|
|
330
|
+
component: targets[0]?.component ?? null,
|
|
331
|
+
targets,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
else if (draft.id !== undefined) {
|
|
335
|
+
const entry = comments.find((c) => c.id === draft.id);
|
|
336
|
+
if (entry)
|
|
337
|
+
entry.text = text;
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
comments.push({
|
|
341
|
+
id: comments.length + 1,
|
|
342
|
+
el: draft.el,
|
|
343
|
+
ratioX: draft.ratioX,
|
|
344
|
+
ratioY: draft.ratioY,
|
|
345
|
+
text,
|
|
346
|
+
source: getSourceLocation(draft.el),
|
|
347
|
+
component: getComponentInfo(draft.el),
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
draft = null;
|
|
351
|
+
unlockHighlight();
|
|
352
|
+
clearSelection();
|
|
353
|
+
clearPreview();
|
|
354
|
+
saveToStorage();
|
|
355
|
+
render();
|
|
356
|
+
}
|
|
357
|
+
function cancelDraft() {
|
|
358
|
+
const node = root?.querySelector('.kapi-comment-composer')?.closest('.kapi-comment');
|
|
359
|
+
if (node)
|
|
360
|
+
animateOut(node);
|
|
361
|
+
draft = null;
|
|
362
|
+
unlockHighlight();
|
|
363
|
+
clearSelection();
|
|
364
|
+
clearPreview();
|
|
365
|
+
render();
|
|
366
|
+
}
|
|
367
|
+
export function cancelOpenDraft() {
|
|
368
|
+
if (draft)
|
|
369
|
+
cancelDraft();
|
|
370
|
+
}
|
|
371
|
+
// Builds one prompt covering comments from every page, read straight from
|
|
372
|
+
// localStorage (each page persists under `kapi-comments:<pathname>`). Other
|
|
373
|
+
// pages aren't in the DOM, so locations come from the stored selector/source
|
|
374
|
+
// rather than a live element.
|
|
375
|
+
export function buildAllCommentsPrompt() {
|
|
376
|
+
const describe = (selector, source, component) => {
|
|
377
|
+
const location = source ? `${source.file}:${source.line}:${source.column}` : selector;
|
|
378
|
+
return `${component ? `<${component.name}> ` : ''}${location}`;
|
|
379
|
+
};
|
|
380
|
+
const sections = [];
|
|
381
|
+
let n = 0; // one running number shared across every page
|
|
382
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
383
|
+
const key = localStorage.key(i);
|
|
384
|
+
if (!key?.startsWith('kapi-comments:'))
|
|
385
|
+
continue;
|
|
386
|
+
let data;
|
|
387
|
+
try {
|
|
388
|
+
data = JSON.parse(localStorage.getItem(key) || '[]');
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
continue; // skip corrupt entry
|
|
392
|
+
}
|
|
393
|
+
if (!data.length)
|
|
394
|
+
continue;
|
|
395
|
+
const lines = data.map((c) => {
|
|
396
|
+
const locations = c.targets?.length
|
|
397
|
+
? c.targets.map((t) => describe(t.selector, t.source, t.component)).join(', ')
|
|
398
|
+
: describe(c.selector, c.source, c.component);
|
|
399
|
+
return `${++n}. [${locations}] feedback: ${c.text}`;
|
|
400
|
+
});
|
|
401
|
+
sections.push([`## Page: ${key.slice('kapi-comments:'.length)}`, ...lines].join('\n'));
|
|
402
|
+
}
|
|
403
|
+
if (!sections.length)
|
|
404
|
+
return null;
|
|
405
|
+
return [
|
|
406
|
+
'Address each of the following review comments left on specific elements in this app, grouped by page:',
|
|
407
|
+
'',
|
|
408
|
+
...sections,
|
|
409
|
+
].join('\n\n');
|
|
410
|
+
}
|
|
411
|
+
export function clearAllComments() {
|
|
412
|
+
root?.querySelectorAll('.kapi-comment').forEach((n) => animateOut(n));
|
|
413
|
+
comments = [];
|
|
414
|
+
if (draft) {
|
|
415
|
+
draft = null;
|
|
416
|
+
unlockHighlight();
|
|
417
|
+
clearPreview();
|
|
418
|
+
}
|
|
419
|
+
try {
|
|
420
|
+
localStorage.removeItem(storageKey);
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
/* ignore (storage disabled) */
|
|
424
|
+
}
|
|
425
|
+
render();
|
|
426
|
+
}
|
|
427
|
+
export function beginComment(el, clientX, clientY) {
|
|
428
|
+
if (isDisabled() || draft)
|
|
429
|
+
return;
|
|
430
|
+
const rect = el.getBoundingClientRect();
|
|
431
|
+
const ratioX = rect.width > 0 ? clamp((clientX - rect.left) / rect.width, 0, 1) : 0.5;
|
|
432
|
+
const ratioY = rect.height > 0 ? clamp((clientY - rect.top) / rect.height, 0, 1) : 0.5;
|
|
433
|
+
draft = { el, ratioX, ratioY };
|
|
434
|
+
lockHighlightOn(el);
|
|
435
|
+
render();
|
|
436
|
+
}
|
|
437
|
+
// Called on every shift-click/drag-select change in inspector.ts. Keeps one
|
|
438
|
+
// shared composer live as the selection grows or shrinks, so no separate
|
|
439
|
+
// "commit" click is needed. On submit, one CommentEntry per selected element
|
|
440
|
+
// is created with the same text.
|
|
441
|
+
export function updateSelection(els) {
|
|
442
|
+
if (isDisabled())
|
|
443
|
+
return;
|
|
444
|
+
if (els.length === 0) {
|
|
445
|
+
if (draft?.els)
|
|
446
|
+
cancelDraft();
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
// A normal single comment/edit is already in progress (reachable if a
|
|
450
|
+
// shift-click lands while editing an existing comment) — leave it alone.
|
|
451
|
+
if (draft && !draft.els)
|
|
452
|
+
return;
|
|
453
|
+
const anchor = els[els.length - 1];
|
|
454
|
+
if (draft?.els) {
|
|
455
|
+
// Preserve whatever the user has typed so far across the re-render.
|
|
456
|
+
const input = root?.querySelector('.kapi-comment-input');
|
|
457
|
+
if (input)
|
|
458
|
+
draft.text = input.value;
|
|
459
|
+
draft.els = els;
|
|
460
|
+
draft.el = anchor;
|
|
461
|
+
}
|
|
462
|
+
else {
|
|
463
|
+
draft = { el: anchor, ratioX: 0.5, ratioY: 0.5, els };
|
|
464
|
+
}
|
|
465
|
+
lockWithoutHighlight();
|
|
466
|
+
render();
|
|
467
|
+
}
|
|
468
|
+
function deleteComment(id) {
|
|
469
|
+
const node = root?.querySelector('.kapi-comment-composer')?.closest('.kapi-comment');
|
|
470
|
+
if (node)
|
|
471
|
+
animateOut(node);
|
|
472
|
+
comments = comments.filter((c) => c.id !== id);
|
|
473
|
+
// Renumber so ids stay contiguous (1..n) and match marker labels.
|
|
474
|
+
comments.forEach((c, i) => (c.id = i + 1));
|
|
475
|
+
draft = null;
|
|
476
|
+
unlockHighlight();
|
|
477
|
+
clearPreview();
|
|
478
|
+
saveToStorage();
|
|
479
|
+
render();
|
|
480
|
+
}
|
|
481
|
+
function beginEdit(entry) {
|
|
482
|
+
if (isDisabled())
|
|
483
|
+
return;
|
|
484
|
+
if (draft)
|
|
485
|
+
cancelDraft();
|
|
486
|
+
draft = { el: entry.el, ratioX: entry.ratioX, ratioY: entry.ratioY, id: entry.id, text: entry.text };
|
|
487
|
+
if (entry.targets && entry.targets.length > 1) {
|
|
488
|
+
previewElements(entry.targets.map((t) => t.el));
|
|
489
|
+
lockWithoutHighlight();
|
|
490
|
+
}
|
|
491
|
+
else {
|
|
492
|
+
lockHighlightOn(entry.el);
|
|
493
|
+
}
|
|
494
|
+
render();
|
|
495
|
+
}
|
|
496
|
+
// In an SPA, client-side navigation (Vue Router, etc.) changes
|
|
497
|
+
// location.pathname without reloading this script. Patch history's
|
|
498
|
+
// navigation methods and listen for back/forward/hash changes so comments
|
|
499
|
+
// stay scoped to whichever page is actually showing.
|
|
500
|
+
function handleNavigation() {
|
|
501
|
+
if (location.pathname === currentPathname)
|
|
502
|
+
return;
|
|
503
|
+
currentPathname = location.pathname;
|
|
504
|
+
storageKey = `kapi-comments:${currentPathname}`;
|
|
505
|
+
if (draft)
|
|
506
|
+
cancelDraft();
|
|
507
|
+
comments = [];
|
|
508
|
+
// Vue Router updates the DOM asynchronously after pushState/popstate fires,
|
|
509
|
+
// so querying for saved selectors must wait a frame for the new page to render.
|
|
510
|
+
requestAnimationFrame(() => {
|
|
511
|
+
loadFromStorage();
|
|
512
|
+
render();
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
function watchForNavigation() {
|
|
516
|
+
const originalPushState = history.pushState.bind(history);
|
|
517
|
+
history.pushState = function (...args) {
|
|
518
|
+
originalPushState(...args);
|
|
519
|
+
handleNavigation();
|
|
520
|
+
};
|
|
521
|
+
const originalReplaceState = history.replaceState.bind(history);
|
|
522
|
+
history.replaceState = function (...args) {
|
|
523
|
+
originalReplaceState(...args);
|
|
524
|
+
handleNavigation();
|
|
525
|
+
};
|
|
526
|
+
window.addEventListener('popstate', handleNavigation);
|
|
527
|
+
window.addEventListener('hashchange', handleNavigation);
|
|
528
|
+
}
|
|
529
|
+
watchForNavigation();
|
|
530
|
+
loadFromStorage();
|
|
531
|
+
render();
|
|
@@ -1,16 +1,24 @@
|
|
|
1
|
+
import { renderComponentBadge, describeElement } from './inspector.js';
|
|
1
2
|
const PANEL_TAG = 'kapi-hover-panel';
|
|
3
|
+
const GAP = 6; // px between the hovered element and the panel
|
|
4
|
+
const VIEWPORT_MARGIN = 12;
|
|
5
|
+
const CORNER_OFFSET = 20; // fallback position (no anchor element) — top-right corner
|
|
2
6
|
const STYLES = `
|
|
3
7
|
:host {
|
|
4
8
|
all: initial;
|
|
5
|
-
position: fixed;
|
|
6
|
-
top: 20px;
|
|
7
|
-
right: 20px;
|
|
8
|
-
z-index: 2147483647;
|
|
9
9
|
color-scheme: dark;
|
|
10
|
+
/* Purely informational — never intercept hits, so elementsFromPoint()
|
|
11
|
+
in inspector.ts sees straight through to the element under the cursor
|
|
12
|
+
even when the panel is positioned right on top of it. */
|
|
13
|
+
pointer-events: none;
|
|
10
14
|
}
|
|
11
15
|
|
|
12
16
|
.kapi-hover-panel {
|
|
13
17
|
display: none;
|
|
18
|
+
position: fixed;
|
|
19
|
+
top: 0;
|
|
20
|
+
left: 0;
|
|
21
|
+
z-index: 2147483647;
|
|
14
22
|
max-width: 360px;
|
|
15
23
|
box-sizing: border-box;
|
|
16
24
|
padding: 8px 12px;
|
|
@@ -25,6 +33,10 @@ const STYLES = `
|
|
|
25
33
|
line-height: 1.5;
|
|
26
34
|
color: rgba(255, 255, 255, 0.75);
|
|
27
35
|
word-break: break-word;
|
|
36
|
+
/* Slides to the new position when hover moves between elements. Has no
|
|
37
|
+
effect on first appearance — the panel goes straight from display:none
|
|
38
|
+
to its initial position, with no prior rendered frame to animate from. */
|
|
39
|
+
transition: transform 120ms ease;
|
|
28
40
|
}
|
|
29
41
|
|
|
30
42
|
.kapi-hover-panel.kapi-visible {
|
|
@@ -36,9 +48,13 @@ const STYLES = `
|
|
|
36
48
|
font-weight: 600;
|
|
37
49
|
}
|
|
38
50
|
|
|
39
|
-
.kapi-hover-panel-
|
|
51
|
+
.kapi-hover-panel-component {
|
|
40
52
|
color: rgb(74, 222, 128);
|
|
41
53
|
font-weight: 600;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.kapi-hover-panel-source {
|
|
57
|
+
color: rgba(255, 255, 255, 0.5);
|
|
42
58
|
margin-bottom: 2px;
|
|
43
59
|
}
|
|
44
60
|
|
|
@@ -84,28 +100,57 @@ function ensurePanel() {
|
|
|
84
100
|
document.body.appendChild(host);
|
|
85
101
|
return panel;
|
|
86
102
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
103
|
+
// Anchors the panel just below-left of `anchorRect` (the hovered element),
|
|
104
|
+
// flipping above it if there's no room below, and clamping to the viewport.
|
|
105
|
+
// With no anchor (e.g. during processing, when nothing is hovered), falls
|
|
106
|
+
// back to a fixed top-right corner.
|
|
107
|
+
function position(anchorRect) {
|
|
108
|
+
const panelEl = ensurePanel();
|
|
109
|
+
const panelRect = panelEl.getBoundingClientRect();
|
|
110
|
+
let left;
|
|
111
|
+
let top;
|
|
112
|
+
if (anchorRect) {
|
|
113
|
+
left = anchorRect.left;
|
|
114
|
+
top = anchorRect.bottom + GAP;
|
|
115
|
+
if (top + panelRect.height > window.innerHeight - VIEWPORT_MARGIN) {
|
|
116
|
+
top = anchorRect.top - panelRect.height - GAP;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
left = window.innerWidth - panelRect.width - CORNER_OFFSET;
|
|
121
|
+
top = CORNER_OFFSET;
|
|
122
|
+
}
|
|
123
|
+
left = Math.min(Math.max(left, VIEWPORT_MARGIN), window.innerWidth - panelRect.width - VIEWPORT_MARGIN);
|
|
124
|
+
top = Math.max(top, VIEWPORT_MARGIN);
|
|
125
|
+
panelEl.style.transform = `translate(${left}px, ${top}px)`;
|
|
126
|
+
}
|
|
127
|
+
export function updateHoverPanel(el) {
|
|
128
|
+
const panelEl = ensurePanel();
|
|
129
|
+
if (!el) {
|
|
130
|
+
panelEl.classList.remove('kapi-visible');
|
|
91
131
|
return;
|
|
92
132
|
}
|
|
93
|
-
el
|
|
133
|
+
const location = describeElement(el);
|
|
134
|
+
panelEl.replaceChildren();
|
|
135
|
+
if (location.component) {
|
|
136
|
+
panelEl.appendChild(renderComponentBadge(location.component, 'kapi-hover-panel-component'));
|
|
137
|
+
}
|
|
94
138
|
if (location.source) {
|
|
95
139
|
const sourceEl = document.createElement('div');
|
|
96
140
|
sourceEl.className = 'kapi-hover-panel-source';
|
|
97
141
|
sourceEl.textContent = `${location.source.file}:${location.source.line}:${location.source.column}`;
|
|
98
|
-
|
|
142
|
+
panelEl.appendChild(sourceEl);
|
|
99
143
|
}
|
|
100
144
|
const selectorEl = document.createElement('div');
|
|
101
145
|
selectorEl.className = 'kapi-hover-panel-selector';
|
|
102
146
|
selectorEl.textContent = location.selector;
|
|
103
|
-
|
|
104
|
-
|
|
147
|
+
panelEl.appendChild(selectorEl);
|
|
148
|
+
panelEl.classList.add('kapi-visible');
|
|
149
|
+
position(el.getBoundingClientRect());
|
|
105
150
|
}
|
|
106
151
|
export function showProcessingStatus(status) {
|
|
107
|
-
const
|
|
108
|
-
|
|
152
|
+
const panelEl = ensurePanel();
|
|
153
|
+
panelEl.replaceChildren();
|
|
109
154
|
const row = document.createElement('div');
|
|
110
155
|
row.className = 'kapi-hover-panel-status';
|
|
111
156
|
const dot = document.createElement('span');
|
|
@@ -113,6 +158,7 @@ export function showProcessingStatus(status) {
|
|
|
113
158
|
const text = document.createElement('span');
|
|
114
159
|
text.textContent = status;
|
|
115
160
|
row.append(dot, text);
|
|
116
|
-
|
|
117
|
-
|
|
161
|
+
panelEl.appendChild(row);
|
|
162
|
+
panelEl.classList.add('kapi-visible');
|
|
163
|
+
position(null);
|
|
118
164
|
}
|