opencode-browser-annotation-plugin 0.1.3 → 0.2.1
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 +34 -15
- package/dist/plugin.js +121 -48
- package/extension/background.js +44 -38
- package/extension/manifest.json +24 -9
- package/extension/options.html +25 -46
- package/extension/overlay.js +521 -0
- package/package.json +1 -1
- package/extension/content.js +0 -126
- package/extension/popup.html +0 -88
- package/extension/popup.js +0 -71
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
// Overlay content script: a picking layer plus a right sidebar, rendered inside
|
|
2
|
+
// a shadow root so the host page's CSS cannot interfere and ours cannot leak.
|
|
3
|
+
// Toggled via Alt+A (background command) or the toolbar icon.
|
|
4
|
+
//
|
|
5
|
+
// Flow: pick an element (hover highlight + click, shadow-DOM aware via
|
|
6
|
+
// composedPath) -> type an instruction in a card -> choose Act/Queue ->
|
|
7
|
+
// Submit sends all cards to the background worker, which POSTs to the plugin.
|
|
8
|
+
|
|
9
|
+
(() => {
|
|
10
|
+
if (window.__ocAnnotationInjected) {
|
|
11
|
+
window.dispatchEvent(new CustomEvent("oc-annotation-toggle"));
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
window.__ocAnnotationInjected = true;
|
|
15
|
+
|
|
16
|
+
const SIDEBAR_W = 348;
|
|
17
|
+
|
|
18
|
+
const ICON = {
|
|
19
|
+
target:
|
|
20
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="7"/><line x1="12" y1="1" x2="12" y2="5"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="1" y1="12" x2="5" y2="12"/><line x1="19" y1="12" x2="23" y2="12"/></svg>',
|
|
21
|
+
close:
|
|
22
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>',
|
|
23
|
+
trash:
|
|
24
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>',
|
|
25
|
+
bolt:
|
|
26
|
+
'<svg viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
|
|
27
|
+
layers:
|
|
28
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>',
|
|
29
|
+
send:
|
|
30
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>',
|
|
31
|
+
logo:
|
|
32
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3H5a2 2 0 0 0-2 2v4m6-6h10a2 2 0 0 1 2 2v4M9 3v18m0 0H5a2 2 0 0 1-2-2v-4m6 6h10a2 2 0 0 0 2-2v-4"/></svg>',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const STYLE = `
|
|
36
|
+
:host { all: initial; }
|
|
37
|
+
* { box-sizing: border-box; }
|
|
38
|
+
#oc-layer { position: fixed; inset: 0; pointer-events: none; z-index: 1; }
|
|
39
|
+
#oc-highlight {
|
|
40
|
+
position: fixed; display: none; pointer-events: none; z-index: 2;
|
|
41
|
+
border: 2px solid #4c8dff; background: rgba(76,141,255,0.14);
|
|
42
|
+
border-radius: 3px; box-shadow: 0 0 0 1px rgba(0,0,0,.4);
|
|
43
|
+
transition: all .04s ease-out;
|
|
44
|
+
}
|
|
45
|
+
#oc-hint {
|
|
46
|
+
position: fixed; top: 18px; left: calc(50% - ${SIDEBAR_W / 2}px);
|
|
47
|
+
transform: translateX(-50%); z-index: 3;
|
|
48
|
+
display: none; gap: 10px; align-items: center; pointer-events: none;
|
|
49
|
+
font: 13px system-ui, sans-serif; color: #e6e8ee;
|
|
50
|
+
background: rgba(20,22,30,.94); padding: 8px 14px; border-radius: 999px;
|
|
51
|
+
box-shadow: 0 8px 24px rgba(0,0,0,.4); border: 1px solid rgba(255,255,255,.08);
|
|
52
|
+
}
|
|
53
|
+
#oc-hint .key { font-size: 11px; background: rgba(255,255,255,.14); padding: 2px 7px; border-radius: 5px; }
|
|
54
|
+
|
|
55
|
+
#oc-sidebar {
|
|
56
|
+
position: fixed; top: 0; right: 0; height: 100vh; width: ${SIDEBAR_W}px;
|
|
57
|
+
pointer-events: auto; display: flex; flex-direction: column; z-index: 4;
|
|
58
|
+
background: #16171d; color: #e6e8ee;
|
|
59
|
+
font: 13.5px/1.5 system-ui, -apple-system, sans-serif;
|
|
60
|
+
border-left: 1px solid rgba(255,255,255,.08);
|
|
61
|
+
box-shadow: -12px 0 40px rgba(0,0,0,.45);
|
|
62
|
+
}
|
|
63
|
+
#oc-sidebar header {
|
|
64
|
+
display: flex; align-items: center; gap: 9px;
|
|
65
|
+
padding: 14px 14px; border-bottom: 1px solid rgba(255,255,255,.07);
|
|
66
|
+
}
|
|
67
|
+
#oc-sidebar header .logo { width: 18px; height: 18px; color: #4c8dff; flex: none; }
|
|
68
|
+
#oc-sidebar header .title { font-weight: 650; font-size: 13.5px; flex: 1; letter-spacing: .2px; }
|
|
69
|
+
.iconbtn {
|
|
70
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
71
|
+
border: none; background: transparent; color: #8b90a0; cursor: pointer;
|
|
72
|
+
width: 30px; height: 30px; border-radius: 8px; padding: 0;
|
|
73
|
+
}
|
|
74
|
+
.iconbtn svg { width: 16px; height: 16px; }
|
|
75
|
+
.iconbtn:hover { background: rgba(255,255,255,.07); color: #e6e8ee; }
|
|
76
|
+
|
|
77
|
+
.oc-actions { padding: 12px 14px 6px; }
|
|
78
|
+
button.primary {
|
|
79
|
+
font: inherit; font-weight: 600; width: 100%;
|
|
80
|
+
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
|
81
|
+
padding: 10px 12px; border-radius: 10px; cursor: pointer;
|
|
82
|
+
color: #fff; background: #3f7dff; border: 1px solid #3f7dff;
|
|
83
|
+
transition: background .15s, transform .05s;
|
|
84
|
+
}
|
|
85
|
+
button.primary svg { width: 16px; height: 16px; }
|
|
86
|
+
button.primary:hover:not(:disabled) { background: #3670f0; }
|
|
87
|
+
button.primary:active:not(:disabled) { transform: translateY(1px); }
|
|
88
|
+
button.primary:disabled { opacity: .4; cursor: default; }
|
|
89
|
+
#oc-pick.active { background: #e0632a; border-color: #e0632a; }
|
|
90
|
+
|
|
91
|
+
#oc-list { flex: 1; overflow-y: auto; padding: 10px 14px; display: flex; flex-direction: column; gap: 10px; }
|
|
92
|
+
#oc-list::-webkit-scrollbar { width: 10px; }
|
|
93
|
+
#oc-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,.12); border-radius: 6px; border: 3px solid #16171d; }
|
|
94
|
+
.oc-empty { color: #71768a; font-size: 12.5px; padding: 26px 8px; text-align: center; line-height: 1.6; }
|
|
95
|
+
|
|
96
|
+
.oc-card {
|
|
97
|
+
background: #1f2129; border: 1px solid rgba(255,255,255,.08); border-radius: 12px;
|
|
98
|
+
padding: 10px; box-shadow: 0 1px 2px rgba(0,0,0,.3);
|
|
99
|
+
}
|
|
100
|
+
.oc-card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
|
|
101
|
+
.oc-desc {
|
|
102
|
+
font: 11.5px ui-monospace, monospace; color: #aeb4c6;
|
|
103
|
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
104
|
+
background: rgba(255,255,255,.06); padding: 3px 8px; border-radius: 6px; flex: 1;
|
|
105
|
+
}
|
|
106
|
+
.oc-card textarea {
|
|
107
|
+
width: 100%; resize: vertical; min-height: 46px; font: inherit;
|
|
108
|
+
border: 1px solid rgba(255,255,255,.1); border-radius: 8px; padding: 8px 9px;
|
|
109
|
+
background: #14151b; color: #e6e8ee;
|
|
110
|
+
}
|
|
111
|
+
.oc-card textarea::placeholder { color: #616678; }
|
|
112
|
+
.oc-card textarea:focus { outline: none; border-color: #4c8dff; box-shadow: 0 0 0 3px rgba(76,141,255,.18); }
|
|
113
|
+
.oc-modes { display: flex; gap: 6px; margin-top: 8px; }
|
|
114
|
+
.oc-mode {
|
|
115
|
+
font: inherit; font-size: 12px; font-weight: 600; flex: 1;
|
|
116
|
+
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
|
117
|
+
padding: 6px 8px; border-radius: 8px; cursor: pointer;
|
|
118
|
+
background: rgba(255,255,255,.05); color: #9298aa; border: 1px solid transparent;
|
|
119
|
+
}
|
|
120
|
+
.oc-mode svg { width: 13px; height: 13px; }
|
|
121
|
+
.oc-mode:hover { background: rgba(255,255,255,.09); color: #cfd3df; }
|
|
122
|
+
.oc-mode.sel { background: rgba(76,141,255,.16); color: #7aa9ff; border-color: rgba(76,141,255,.4); }
|
|
123
|
+
|
|
124
|
+
#oc-sidebar footer { padding: 12px 14px 14px; border-top: 1px solid rgba(255,255,255,.07); position: relative; }
|
|
125
|
+
.oc-status { font-size: 12px; margin-bottom: 9px; display: flex; align-items: center; gap: 8px; color: #9298aa; }
|
|
126
|
+
.oc-status::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: #5b6070; flex: none; }
|
|
127
|
+
.oc-status.good::before { background: #34d399; box-shadow: 0 0 8px rgba(52,211,153,.6); }
|
|
128
|
+
.oc-status.warn::before { background: #fbbf24; }
|
|
129
|
+
.oc-status.bad::before { background: #f87171; }
|
|
130
|
+
.oc-status.checking::before { background: #60a5fa; }
|
|
131
|
+
.oc-status.good { color: #34d399; }
|
|
132
|
+
.oc-status.warn { color: #fbbf24; }
|
|
133
|
+
.oc-status.bad { color: #f87171; }
|
|
134
|
+
|
|
135
|
+
.oc-toast {
|
|
136
|
+
position: absolute; left: 14px; right: 14px; bottom: 62px;
|
|
137
|
+
background: #0e0f14; color: #fff; font-size: 12.5px;
|
|
138
|
+
padding: 9px 12px; border-radius: 9px; opacity: 0; transform: translateY(6px);
|
|
139
|
+
transition: opacity .2s, transform .2s; pointer-events: none;
|
|
140
|
+
box-shadow: 0 10px 30px rgba(0,0,0,.5); border: 1px solid rgba(255,255,255,.08);
|
|
141
|
+
}
|
|
142
|
+
.oc-toast.show { opacity: 1; transform: translateY(0); }
|
|
143
|
+
.oc-toast.bad { border-color: rgba(248,113,113,.5); color: #fca5a5; }
|
|
144
|
+
`;
|
|
145
|
+
|
|
146
|
+
let host = null;
|
|
147
|
+
let root = null;
|
|
148
|
+
let picking = false;
|
|
149
|
+
let annotations = [];
|
|
150
|
+
let statusTimer = null;
|
|
151
|
+
let visible = false;
|
|
152
|
+
|
|
153
|
+
// ---------- element metadata ----------
|
|
154
|
+
|
|
155
|
+
function bestTestId(el) {
|
|
156
|
+
for (const attr of ["data-testid", "data-test", "data-test-id", "data-cy", "data-qa"]) {
|
|
157
|
+
const v = el.getAttribute && el.getAttribute(attr);
|
|
158
|
+
if (v) return v;
|
|
159
|
+
}
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function cssPath(el) {
|
|
164
|
+
if (!(el instanceof Element)) return "";
|
|
165
|
+
if (el.id) return `#${CSS.escape(el.id)}`;
|
|
166
|
+
const parts = [];
|
|
167
|
+
let node = el;
|
|
168
|
+
while (node && node.nodeType === 1 && parts.length < 6) {
|
|
169
|
+
let sel = node.nodeName.toLowerCase();
|
|
170
|
+
if (node.classList && node.classList.length) {
|
|
171
|
+
sel += "." + Array.from(node.classList).slice(0, 3).map((c) => CSS.escape(c)).join(".");
|
|
172
|
+
}
|
|
173
|
+
const parent = node.parentElement;
|
|
174
|
+
if (parent) {
|
|
175
|
+
const sibs = Array.from(parent.children).filter((c) => c.nodeName === node.nodeName);
|
|
176
|
+
if (sibs.length > 1) sel += `:nth-of-type(${sibs.indexOf(node) + 1})`;
|
|
177
|
+
}
|
|
178
|
+
parts.unshift(sel);
|
|
179
|
+
if (node.id) {
|
|
180
|
+
parts[0] = `#${CSS.escape(node.id)}`;
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
node = node.parentElement;
|
|
184
|
+
}
|
|
185
|
+
return parts.join(" > ");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function elementMeta(el, inShadow) {
|
|
189
|
+
const r = el.getBoundingClientRect();
|
|
190
|
+
const classes = el.classList ? Array.from(el.classList) : [];
|
|
191
|
+
const meta = {
|
|
192
|
+
selector: cssPath(el),
|
|
193
|
+
tag: el.tagName,
|
|
194
|
+
id: el.id || undefined,
|
|
195
|
+
name: el.getAttribute("name") || undefined,
|
|
196
|
+
testId: bestTestId(el),
|
|
197
|
+
role: el.getAttribute("role") || undefined,
|
|
198
|
+
ariaLabel: el.getAttribute("aria-label") || undefined,
|
|
199
|
+
classes: classes.length ? classes : undefined,
|
|
200
|
+
text: (el.textContent || "").trim().slice(0, 500) || undefined,
|
|
201
|
+
href: el.getAttribute("href") || undefined,
|
|
202
|
+
src: el.getAttribute("src") || undefined,
|
|
203
|
+
bounds: { x: r.left, y: r.top, width: r.width, height: r.height },
|
|
204
|
+
inShadow: Boolean(inShadow),
|
|
205
|
+
inIframe: window.top !== window.self,
|
|
206
|
+
html: (el.outerHTML || "").slice(0, 800),
|
|
207
|
+
};
|
|
208
|
+
for (const k of Object.keys(meta)) if (meta[k] === undefined) delete meta[k];
|
|
209
|
+
return meta;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function shortDescriptor(el) {
|
|
213
|
+
const tag = (el.tag || "el").toLowerCase();
|
|
214
|
+
const id = el.testId
|
|
215
|
+
? `[data-testid=${el.testId}]`
|
|
216
|
+
: el.id
|
|
217
|
+
? `#${el.id}`
|
|
218
|
+
: el.ariaLabel
|
|
219
|
+
? `[${el.ariaLabel}]`
|
|
220
|
+
: el.classes && el.classes.length
|
|
221
|
+
? `.${el.classes[0]}`
|
|
222
|
+
: "";
|
|
223
|
+
return `${tag}${id}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---------- picking (layer is always pointer-events:none; read the real
|
|
227
|
+
// element from the event's composed path, which pierces shadow DOM) ----------
|
|
228
|
+
|
|
229
|
+
function pickTarget(e) {
|
|
230
|
+
const path = e.composedPath ? e.composedPath() : [e.target];
|
|
231
|
+
for (const n of path) {
|
|
232
|
+
if (n === host) return null; // our UI wrapper
|
|
233
|
+
if (!(n instanceof Element)) continue;
|
|
234
|
+
if (root && root.contains(n)) return null; // hovering our own UI
|
|
235
|
+
const inShadow = n.getRootNode && n.getRootNode() instanceof ShadowRoot;
|
|
236
|
+
return { el: n, inShadow };
|
|
237
|
+
}
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function positionHighlight(el) {
|
|
242
|
+
const box = root.getElementById("oc-highlight");
|
|
243
|
+
if (!box) return;
|
|
244
|
+
const r = el.getBoundingClientRect();
|
|
245
|
+
Object.assign(box.style, {
|
|
246
|
+
display: "block",
|
|
247
|
+
left: `${r.left}px`,
|
|
248
|
+
top: `${r.top}px`,
|
|
249
|
+
width: `${r.width}px`,
|
|
250
|
+
height: `${r.height}px`,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function onMove(e) {
|
|
255
|
+
if (!picking) return;
|
|
256
|
+
const hit = pickTarget(e);
|
|
257
|
+
if (!hit) {
|
|
258
|
+
const box = root.getElementById("oc-highlight");
|
|
259
|
+
if (box) box.style.display = "none";
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
positionHighlight(hit.el);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function onClick(e) {
|
|
266
|
+
if (!picking) return;
|
|
267
|
+
const hit = pickTarget(e);
|
|
268
|
+
if (!hit) return;
|
|
269
|
+
e.preventDefault();
|
|
270
|
+
e.stopPropagation();
|
|
271
|
+
e.stopImmediatePropagation();
|
|
272
|
+
stopPicking();
|
|
273
|
+
addCardForElement(elementMeta(hit.el, hit.inShadow));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function onKey(e) {
|
|
277
|
+
if (e.key === "Escape") {
|
|
278
|
+
if (picking) stopPicking();
|
|
279
|
+
else toggle();
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function startPicking() {
|
|
284
|
+
picking = true;
|
|
285
|
+
document.documentElement.style.cursor = "crosshair";
|
|
286
|
+
root.getElementById("oc-hint").style.display = "flex";
|
|
287
|
+
setPickBtn(true);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function stopPicking() {
|
|
291
|
+
picking = false;
|
|
292
|
+
document.documentElement.style.cursor = "";
|
|
293
|
+
const box = root.getElementById("oc-highlight");
|
|
294
|
+
if (box) box.style.display = "none";
|
|
295
|
+
root.getElementById("oc-hint").style.display = "none";
|
|
296
|
+
setPickBtn(false);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function setPickBtn(active) {
|
|
300
|
+
const btn = root.getElementById("oc-pick");
|
|
301
|
+
if (btn) {
|
|
302
|
+
btn.innerHTML = `${ICON.target}<span>${active ? "Picking… (Esc)" : "Select element"}</span>`;
|
|
303
|
+
btn.classList.toggle("active", active);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ---------- cards ----------
|
|
308
|
+
|
|
309
|
+
function addCardForElement(element) {
|
|
310
|
+
annotations.push({ instruction: "", mode: "act", page: { url: location.href, title: document.title }, element });
|
|
311
|
+
renderCards();
|
|
312
|
+
const areas = root.querySelectorAll(".oc-card textarea");
|
|
313
|
+
const last = areas[areas.length - 1];
|
|
314
|
+
if (last) last.focus();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function escapeHtml(s) {
|
|
318
|
+
return String(s || "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function renderCards() {
|
|
322
|
+
const list = root.getElementById("oc-list");
|
|
323
|
+
list.innerHTML = "";
|
|
324
|
+
if (annotations.length === 0) {
|
|
325
|
+
const empty = document.createElement("div");
|
|
326
|
+
empty.className = "oc-empty";
|
|
327
|
+
empty.textContent = "No annotations yet. Click “Select element”, then click something on the page.";
|
|
328
|
+
list.appendChild(empty);
|
|
329
|
+
}
|
|
330
|
+
annotations.forEach((a, i) => {
|
|
331
|
+
const card = document.createElement("div");
|
|
332
|
+
card.className = "oc-card";
|
|
333
|
+
card.innerHTML = `
|
|
334
|
+
<div class="oc-card-head">
|
|
335
|
+
<span class="oc-desc" title="${escapeHtml(a.element.selector || "")}">${escapeHtml(shortDescriptor(a.element))}</span>
|
|
336
|
+
<button class="iconbtn oc-rm" data-i="${i}" title="Remove">${ICON.trash}</button>
|
|
337
|
+
</div>
|
|
338
|
+
<textarea rows="2" placeholder="Instruction for this element…" data-i="${i}">${escapeHtml(a.instruction)}</textarea>
|
|
339
|
+
<div class="oc-modes">
|
|
340
|
+
<button class="oc-mode ${a.mode === "act" ? "sel" : ""}" data-mode="act" data-i="${i}">${ICON.bolt}<span>Act now</span></button>
|
|
341
|
+
<button class="oc-mode ${a.mode === "queue" ? "sel" : ""}" data-mode="queue" data-i="${i}">${ICON.layers}<span>Queue</span></button>
|
|
342
|
+
</div>`;
|
|
343
|
+
list.appendChild(card);
|
|
344
|
+
});
|
|
345
|
+
updateSubmit();
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function updateSubmit() {
|
|
349
|
+
const btn = root.getElementById("oc-submit");
|
|
350
|
+
btn.disabled = annotations.length === 0;
|
|
351
|
+
btn.querySelector("span").textContent = annotations.length
|
|
352
|
+
? `Submit ${annotations.length} to agent`
|
|
353
|
+
: "Submit to agent";
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ---------- status ----------
|
|
357
|
+
|
|
358
|
+
function refreshStatus() {
|
|
359
|
+
const el = root.getElementById("oc-status");
|
|
360
|
+
el.className = "oc-status checking";
|
|
361
|
+
el.textContent = "Checking connection…";
|
|
362
|
+
chrome.runtime.sendMessage({ type: "oc-status" }, (res) => {
|
|
363
|
+
if (chrome.runtime.lastError || !res) {
|
|
364
|
+
el.className = "oc-status bad";
|
|
365
|
+
el.textContent = "Extension error";
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (res.ok && res.data?.ok) {
|
|
369
|
+
if (res.data.activeSession) {
|
|
370
|
+
const q = res.data.queued ? ` · ${res.data.queued} queued` : "";
|
|
371
|
+
el.className = "oc-status good";
|
|
372
|
+
el.textContent = `Connected${q}`;
|
|
373
|
+
} else {
|
|
374
|
+
el.className = "oc-status warn";
|
|
375
|
+
el.textContent = "Connected — send a message in OpenCode first";
|
|
376
|
+
}
|
|
377
|
+
} else {
|
|
378
|
+
el.className = "oc-status bad";
|
|
379
|
+
el.textContent = "Not connected — check the SSH tunnel";
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ---------- submit ----------
|
|
385
|
+
|
|
386
|
+
function collectInstructions() {
|
|
387
|
+
root.querySelectorAll(".oc-card textarea").forEach((ta) => {
|
|
388
|
+
const i = Number(ta.dataset.i);
|
|
389
|
+
if (annotations[i]) annotations[i].instruction = ta.value.trim();
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function submit() {
|
|
394
|
+
collectInstructions();
|
|
395
|
+
if (annotations.length === 0) return;
|
|
396
|
+
const btn = root.getElementById("oc-submit");
|
|
397
|
+
btn.disabled = true;
|
|
398
|
+
setToast("Submitting…");
|
|
399
|
+
chrome.runtime.sendMessage({ type: "oc-submit", annotations }, (res) => {
|
|
400
|
+
if (chrome.runtime.lastError || !res) {
|
|
401
|
+
setToast("Extension error", true);
|
|
402
|
+
updateSubmit();
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (res.ok) {
|
|
406
|
+
const parts = [];
|
|
407
|
+
if (res.injected) parts.push(`${res.injected} sent`);
|
|
408
|
+
if (res.queued) parts.push(`${res.queued} queued`);
|
|
409
|
+
setToast(parts.length ? parts.join(", ") : "Submitted");
|
|
410
|
+
annotations = [];
|
|
411
|
+
renderCards();
|
|
412
|
+
refreshStatus();
|
|
413
|
+
} else {
|
|
414
|
+
setToast(`Failed: ${res.error || "unknown"}`, true);
|
|
415
|
+
updateSubmit();
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function setToast(text, bad) {
|
|
421
|
+
const t = root.getElementById("oc-toast");
|
|
422
|
+
t.textContent = text;
|
|
423
|
+
t.className = "oc-toast show" + (bad ? " bad" : "");
|
|
424
|
+
clearTimeout(t.__timer);
|
|
425
|
+
t.__timer = setTimeout(() => (t.className = "oc-toast"), 4000);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ---------- UI ----------
|
|
429
|
+
|
|
430
|
+
function buildUI() {
|
|
431
|
+
host = document.createElement("div");
|
|
432
|
+
host.id = "oc-annotation-host";
|
|
433
|
+
host.style.cssText = "all: initial;";
|
|
434
|
+
root = host.attachShadow({ mode: "open" });
|
|
435
|
+
root.innerHTML = `
|
|
436
|
+
<style>${STYLE}</style>
|
|
437
|
+
<div id="oc-layer"></div>
|
|
438
|
+
<div id="oc-highlight"></div>
|
|
439
|
+
<div id="oc-hint"><span>Hover an element and click to annotate</span><span class="key">Esc</span></div>
|
|
440
|
+
<aside id="oc-sidebar">
|
|
441
|
+
<header>
|
|
442
|
+
<span class="logo">${ICON.logo}</span>
|
|
443
|
+
<span class="title">OpenCode Annotation</span>
|
|
444
|
+
<button id="oc-close" class="iconbtn" title="Close (Alt+A)">${ICON.close}</button>
|
|
445
|
+
</header>
|
|
446
|
+
<div class="oc-actions"><button id="oc-pick" class="primary">${ICON.target}<span>Select element</span></button></div>
|
|
447
|
+
<div id="oc-list"></div>
|
|
448
|
+
<footer>
|
|
449
|
+
<div id="oc-status" class="oc-status">…</div>
|
|
450
|
+
<button id="oc-submit" class="primary" disabled>${ICON.send}<span>Submit to agent</span></button>
|
|
451
|
+
<div id="oc-toast" class="oc-toast"></div>
|
|
452
|
+
</footer>
|
|
453
|
+
</aside>`;
|
|
454
|
+
document.documentElement.appendChild(host);
|
|
455
|
+
|
|
456
|
+
root.getElementById("oc-pick").addEventListener("click", () => (picking ? stopPicking() : startPicking()));
|
|
457
|
+
root.getElementById("oc-close").addEventListener("click", () => toggle());
|
|
458
|
+
root.getElementById("oc-submit").addEventListener("click", submit);
|
|
459
|
+
root.getElementById("oc-list").addEventListener("click", (e) => {
|
|
460
|
+
const rm = e.target.closest(".oc-rm");
|
|
461
|
+
if (rm) {
|
|
462
|
+
annotations.splice(Number(rm.dataset.i), 1);
|
|
463
|
+
renderCards();
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
const mode = e.target.closest(".oc-mode");
|
|
467
|
+
if (mode) {
|
|
468
|
+
annotations[Number(mode.dataset.i)].mode = mode.dataset.mode;
|
|
469
|
+
renderCards();
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
renderCards();
|
|
474
|
+
refreshStatus();
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function pushPage(on) {
|
|
478
|
+
const el = document.documentElement;
|
|
479
|
+
el.style.transition = "margin-right .22s ease";
|
|
480
|
+
el.style.marginRight = on ? `${SIDEBAR_W}px` : "";
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function open() {
|
|
484
|
+
if (!host) buildUI();
|
|
485
|
+
else host.style.display = "";
|
|
486
|
+
pushPage(true);
|
|
487
|
+
document.addEventListener("mousemove", onMove, true);
|
|
488
|
+
document.addEventListener("click", onClick, true);
|
|
489
|
+
document.addEventListener("keydown", onKey, true);
|
|
490
|
+
if (!statusTimer) statusTimer = setInterval(refreshStatus, 15000);
|
|
491
|
+
refreshStatus();
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function close() {
|
|
495
|
+
stopPicking();
|
|
496
|
+
pushPage(false);
|
|
497
|
+
document.removeEventListener("mousemove", onMove, true);
|
|
498
|
+
document.removeEventListener("click", onClick, true);
|
|
499
|
+
document.removeEventListener("keydown", onKey, true);
|
|
500
|
+
if (host) host.style.display = "none";
|
|
501
|
+
if (statusTimer) {
|
|
502
|
+
clearInterval(statusTimer);
|
|
503
|
+
statusTimer = null;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function toggle() {
|
|
508
|
+
visible = !visible;
|
|
509
|
+
if (visible) open();
|
|
510
|
+
else close();
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
window.addEventListener("oc-annotation-toggle", toggle);
|
|
514
|
+
chrome.runtime.onMessage.addListener((msg) => {
|
|
515
|
+
if (msg?.type === "oc-toggle") toggle();
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
// First injection opens immediately.
|
|
519
|
+
visible = true;
|
|
520
|
+
open();
|
|
521
|
+
})();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-browser-annotation-plugin",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Select an element in your browser, type an instruction, and send it to your OpenCode agent over a loopback + SSH tunnel. Text and element metadata only (no screenshots).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"opencode",
|
package/extension/content.js
DELETED
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
// Content script: element picker + annotation capture.
|
|
2
|
-
// Injected on demand by the popup. Highlights elements on hover, captures a
|
|
3
|
-
// selected element's metadata plus a typed instruction, and stores annotations
|
|
4
|
-
// via the background service worker.
|
|
5
|
-
|
|
6
|
-
(() => {
|
|
7
|
-
if (window.__ocAnnotationActive) return;
|
|
8
|
-
window.__ocAnnotationActive = true;
|
|
9
|
-
|
|
10
|
-
const HIGHLIGHT_ID = "__oc-annotation-highlight";
|
|
11
|
-
let overlay = null;
|
|
12
|
-
let current = null;
|
|
13
|
-
|
|
14
|
-
function makeOverlay() {
|
|
15
|
-
const el = document.createElement("div");
|
|
16
|
-
el.id = HIGHLIGHT_ID;
|
|
17
|
-
Object.assign(el.style, {
|
|
18
|
-
position: "fixed",
|
|
19
|
-
pointerEvents: "none",
|
|
20
|
-
zIndex: "2147483646",
|
|
21
|
-
border: "2px solid #2b7cff",
|
|
22
|
-
background: "rgba(43,124,255,0.12)",
|
|
23
|
-
borderRadius: "2px",
|
|
24
|
-
transition: "all 0.03s ease-out",
|
|
25
|
-
display: "none",
|
|
26
|
-
});
|
|
27
|
-
document.documentElement.appendChild(el);
|
|
28
|
-
return el;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function positionOverlay(target) {
|
|
32
|
-
if (!overlay) return;
|
|
33
|
-
const r = target.getBoundingClientRect();
|
|
34
|
-
Object.assign(overlay.style, {
|
|
35
|
-
display: "block",
|
|
36
|
-
left: `${r.left}px`,
|
|
37
|
-
top: `${r.top}px`,
|
|
38
|
-
width: `${r.width}px`,
|
|
39
|
-
height: `${r.height}px`,
|
|
40
|
-
});
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function cssPath(el) {
|
|
44
|
-
if (!(el instanceof Element)) return "";
|
|
45
|
-
if (el.id) return `#${CSS.escape(el.id)}`;
|
|
46
|
-
const parts = [];
|
|
47
|
-
let node = el;
|
|
48
|
-
while (node && node.nodeType === 1 && parts.length < 5) {
|
|
49
|
-
let selector = node.nodeName.toLowerCase();
|
|
50
|
-
if (node.classList.length) {
|
|
51
|
-
selector += "." + Array.from(node.classList).slice(0, 3).map((c) => CSS.escape(c)).join(".");
|
|
52
|
-
}
|
|
53
|
-
const parent = node.parentElement;
|
|
54
|
-
if (parent) {
|
|
55
|
-
const siblings = Array.from(parent.children).filter((c) => c.nodeName === node.nodeName);
|
|
56
|
-
if (siblings.length > 1) {
|
|
57
|
-
selector += `:nth-of-type(${siblings.indexOf(node) + 1})`;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
parts.unshift(selector);
|
|
61
|
-
if (node.id) {
|
|
62
|
-
parts[0] = `#${CSS.escape(node.id)}`;
|
|
63
|
-
break;
|
|
64
|
-
}
|
|
65
|
-
node = node.parentElement;
|
|
66
|
-
}
|
|
67
|
-
return parts.join(" > ");
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function elementMeta(el) {
|
|
71
|
-
const r = el.getBoundingClientRect();
|
|
72
|
-
return {
|
|
73
|
-
selector: cssPath(el),
|
|
74
|
-
tag: el.tagName,
|
|
75
|
-
text: (el.textContent || "").trim().slice(0, 500),
|
|
76
|
-
role: el.getAttribute("role") || undefined,
|
|
77
|
-
ariaLabel: el.getAttribute("aria-label") || undefined,
|
|
78
|
-
bounds: { x: r.left, y: r.top, width: r.width, height: r.height },
|
|
79
|
-
html: el.outerHTML.slice(0, 800),
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function cleanup() {
|
|
84
|
-
document.removeEventListener("mousemove", onMove, true);
|
|
85
|
-
document.removeEventListener("click", onClick, true);
|
|
86
|
-
document.removeEventListener("keydown", onKey, true);
|
|
87
|
-
if (overlay) overlay.remove();
|
|
88
|
-
overlay = null;
|
|
89
|
-
window.__ocAnnotationActive = false;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function onMove(e) {
|
|
93
|
-
const target = e.target;
|
|
94
|
-
if (!target || target === overlay) return;
|
|
95
|
-
current = target;
|
|
96
|
-
positionOverlay(target);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function onKey(e) {
|
|
100
|
-
if (e.key === "Escape") {
|
|
101
|
-
e.preventDefault();
|
|
102
|
-
cleanup();
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function onClick(e) {
|
|
107
|
-
e.preventDefault();
|
|
108
|
-
e.stopPropagation();
|
|
109
|
-
const target = current || e.target;
|
|
110
|
-
if (!target) return;
|
|
111
|
-
const instruction = window.prompt("Instruction for this element:");
|
|
112
|
-
if (instruction === null) return; // cancelled; keep picking
|
|
113
|
-
const annotation = {
|
|
114
|
-
instruction: instruction.trim(),
|
|
115
|
-
page: { url: location.href, title: document.title },
|
|
116
|
-
element: elementMeta(target),
|
|
117
|
-
};
|
|
118
|
-
chrome.runtime.sendMessage({ type: "oc-add-annotation", annotation }, () => {});
|
|
119
|
-
cleanup();
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
overlay = makeOverlay();
|
|
123
|
-
document.addEventListener("mousemove", onMove, true);
|
|
124
|
-
document.addEventListener("click", onClick, true);
|
|
125
|
-
document.addEventListener("keydown", onKey, true);
|
|
126
|
-
})();
|