opencode-browser-annotation-plugin 0.2.1 → 0.3.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 +12 -9
- package/dist/plugin.js +54 -52
- package/extension/background.js +22 -20
- package/extension/manifest.json +12 -5
- package/extension/options.html +1 -1
- package/extension/overlay.js +364 -310
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -73,15 +73,18 @@ extension's **Settings** page.
|
|
|
73
73
|
## Use
|
|
74
74
|
|
|
75
75
|
1. Send at least one message in OpenCode so the plugin knows the active session.
|
|
76
|
-
2. Press **Alt+A** on any page
|
|
77
|
-
|
|
78
|
-
3. In the
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
76
|
+
2. Press **Alt+A** on any page, then click an element (highlight follows the
|
|
77
|
+
cursor; works inside shadow DOM). A popup opens next to it.
|
|
78
|
+
3. In the popup, type an instruction and choose **Act now** (agent responds
|
|
79
|
+
immediately) or **Queue** (held for context until the next Act). Then either:
|
|
80
|
+
- **Send** (or Cmd/Ctrl+Enter) — submit this one right away, no sidebar needed.
|
|
81
|
+
- **Add to list** — stash it in the sidebar to batch with others.
|
|
82
|
+
4. Press **Alt+Shift+A** (or click the toolbar icon) to open the **list sidebar**,
|
|
83
|
+
review pending annotations, and **Submit** them together. The footer shows the
|
|
84
|
+
connection status; a toast confirms how many were sent vs queued.
|
|
85
|
+
|
|
86
|
+
Shortcuts: `Alt+A` picks an element, `Alt+Shift+A` toggles the list. If they do
|
|
87
|
+
nothing after loading an unpacked build, set them at `chrome://extensions/shortcuts`.
|
|
85
88
|
|
|
86
89
|
## Payload
|
|
87
90
|
|
package/dist/plugin.js
CHANGED
|
@@ -7,9 +7,9 @@ import { createServer } from "node:http";
|
|
|
7
7
|
* the browser runs on a separate desktop, the extension reaches this server over
|
|
8
8
|
* an `ssh -L` local forward (desktop -> host).
|
|
9
9
|
*
|
|
10
|
-
* On receipt the plugin injects a new user turn into the
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* On receipt the plugin injects a new user turn into the chosen OpenCode session
|
|
11
|
+
* (the extension may target any session by id; otherwise the most recently
|
|
12
|
+
* active one) so the agent responds.
|
|
13
13
|
*
|
|
14
14
|
* Scope: text + element metadata only. No screenshots, no image/vision.
|
|
15
15
|
*/
|
|
@@ -142,18 +142,35 @@ export const BrowserAnnotationPlugin = async ({ client, directory }) => {
|
|
|
142
142
|
const host = envHost();
|
|
143
143
|
const port = envPort();
|
|
144
144
|
let activeSessionID = null;
|
|
145
|
-
let activeSessionTitle = null;
|
|
146
145
|
let server = null;
|
|
147
|
-
const queued = [];
|
|
148
146
|
const log = (level, message, extra) => {
|
|
149
147
|
void client.app
|
|
150
148
|
.log({ body: { service: "browser-annotation", level, message, extra } })
|
|
151
149
|
.catch(() => { });
|
|
152
150
|
};
|
|
153
|
-
|
|
151
|
+
/**
|
|
152
|
+
* All sessions live in this one OpenCode process, so the plugin can address
|
|
153
|
+
* any of them by id — a single HTTP port/tunnel does not limit targeting.
|
|
154
|
+
* Returns top-level sessions (no parentID), most-recently-updated first.
|
|
155
|
+
*/
|
|
156
|
+
async function listSessions() {
|
|
157
|
+
try {
|
|
158
|
+
const res = (await client.session.list({ query: { directory } }));
|
|
159
|
+
const rows = Array.isArray(res) ? res : Array.isArray(res?.data) ? res.data : [];
|
|
160
|
+
return rows
|
|
161
|
+
.filter((s) => s && typeof s.id === "string" && !s.parentID)
|
|
162
|
+
.map((s) => ({ id: s.id, title: typeof s.title === "string" ? s.title : s.id, updated: s.time?.updated ?? 0 }))
|
|
163
|
+
.sort((a, b) => b.updated - a.updated);
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
log("warn", `session.list failed: ${error instanceof Error ? error.message : "unknown"}`);
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async function injectPrompt(sessionID, annotations) {
|
|
154
171
|
try {
|
|
155
172
|
await client.session.promptAsync({
|
|
156
|
-
path: { id:
|
|
173
|
+
path: { id: sessionID },
|
|
157
174
|
query: { directory },
|
|
158
175
|
body: { parts: [{ type: "text", text: buildPrompt(annotations) }] },
|
|
159
176
|
});
|
|
@@ -163,34 +180,26 @@ export const BrowserAnnotationPlugin = async ({ client, directory }) => {
|
|
|
163
180
|
return { ok: false, error: error instanceof Error ? error.message : "session.prompt failed" };
|
|
164
181
|
}
|
|
165
182
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
183
|
+
async function handleSubmit(annotations, requestedSessionID) {
|
|
184
|
+
// Prefer the explicitly targeted session; fall back to the last active one.
|
|
185
|
+
let targetID = requestedSessionID || activeSessionID;
|
|
186
|
+
if (requestedSessionID) {
|
|
187
|
+
const sessions = await listSessions();
|
|
188
|
+
if (!sessions.some((s) => s.id === requestedSessionID)) {
|
|
189
|
+
return { ok: false, error: "Target session no longer exists.", injected: 0 };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (!targetID) {
|
|
172
193
|
return {
|
|
173
194
|
ok: false,
|
|
174
|
-
error: "No
|
|
195
|
+
error: "No target session. Send a message in OpenCode first, or pick a session.",
|
|
175
196
|
injected: 0,
|
|
176
|
-
queued: queued.length,
|
|
177
197
|
};
|
|
178
198
|
}
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
return { ok: true, injected: 0, queued: queued.length, sessionID: activeSessionID };
|
|
184
|
-
}
|
|
185
|
-
const batch = [...queued, ...toAct];
|
|
186
|
-
queued.length = 0;
|
|
187
|
-
const result = await injectPrompt(batch);
|
|
188
|
-
if (!result.ok) {
|
|
189
|
-
// Re-queue so nothing is lost.
|
|
190
|
-
queued.unshift(...batch.filter((a) => a.mode === "queue"));
|
|
191
|
-
return { ok: false, error: result.error, injected: 0, queued: queued.length, sessionID: activeSessionID };
|
|
192
|
-
}
|
|
193
|
-
return { ok: true, injected: batch.length, queued: queued.length, sessionID: activeSessionID };
|
|
199
|
+
const result = await injectPrompt(targetID, annotations);
|
|
200
|
+
if (!result.ok)
|
|
201
|
+
return { ok: false, error: result.error, injected: 0, sessionID: targetID };
|
|
202
|
+
return { ok: true, injected: annotations.length, sessionID: targetID };
|
|
194
203
|
}
|
|
195
204
|
function handle(req, res) {
|
|
196
205
|
if (req.method === "OPTIONS") {
|
|
@@ -198,14 +207,17 @@ export const BrowserAnnotationPlugin = async ({ client, directory }) => {
|
|
|
198
207
|
return;
|
|
199
208
|
}
|
|
200
209
|
if (req.method === "GET" && req.url === "/status") {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
210
|
+
void listSessions().then((sessions) => {
|
|
211
|
+
const active = sessions.find((s) => s.id === activeSessionID);
|
|
212
|
+
sendJson(res, 200, {
|
|
213
|
+
ok: true,
|
|
214
|
+
activeSession: Boolean(activeSessionID),
|
|
215
|
+
sessionID: activeSessionID,
|
|
216
|
+
sessionTitle: active?.title ?? null,
|
|
217
|
+
sessions,
|
|
218
|
+
host,
|
|
219
|
+
port,
|
|
220
|
+
});
|
|
209
221
|
});
|
|
210
222
|
return;
|
|
211
223
|
}
|
|
@@ -218,11 +230,9 @@ export const BrowserAnnotationPlugin = async ({ client, directory }) => {
|
|
|
218
230
|
sendJson(res, 400, { ok: false, error: "No annotations in payload." });
|
|
219
231
|
return;
|
|
220
232
|
}
|
|
221
|
-
const result = await handleSubmit(annotations);
|
|
233
|
+
const result = await handleSubmit(annotations, payload.sessionID);
|
|
222
234
|
if (result.ok) {
|
|
223
|
-
log("info", `
|
|
224
|
-
sessionID: result.sessionID,
|
|
225
|
-
});
|
|
235
|
+
log("info", `Injected ${result.injected} annotation(s)`, { sessionID: result.sessionID });
|
|
226
236
|
sendJson(res, 200, result);
|
|
227
237
|
}
|
|
228
238
|
else {
|
|
@@ -263,17 +273,9 @@ export const BrowserAnnotationPlugin = async ({ client, directory }) => {
|
|
|
263
273
|
activeSessionID = input.sessionID;
|
|
264
274
|
},
|
|
265
275
|
event: async ({ event }) => {
|
|
266
|
-
if (event.type === "session.
|
|
267
|
-
|
|
268
|
-
if (info.id === activeSessionID && typeof info.title === "string") {
|
|
269
|
-
activeSessionTitle = info.title;
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
else if (event.type === "session.deleted") {
|
|
273
|
-
if (event.properties.info.id === activeSessionID) {
|
|
276
|
+
if (event.type === "session.deleted") {
|
|
277
|
+
if (event.properties.info.id === activeSessionID)
|
|
274
278
|
activeSessionID = null;
|
|
275
|
-
activeSessionTitle = null;
|
|
276
|
-
}
|
|
277
279
|
}
|
|
278
280
|
},
|
|
279
281
|
};
|
package/extension/background.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Background service worker.
|
|
2
|
-
// -
|
|
3
|
-
// -
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
// - Alt+A -> start element picking
|
|
3
|
+
// - Alt+Shift+A -> toggle the annotation-list sidebar
|
|
4
|
+
// - toolbar click -> toggle the sidebar
|
|
5
|
+
// Performs all network I/O to the plugin endpoint (status + submit), because
|
|
6
|
+
// page CSP can block a content script from fetching localhost.
|
|
6
7
|
|
|
7
8
|
const DEFAULT_ENDPOINT = "http://127.0.0.1:39517";
|
|
8
9
|
const EXTENSION_VERSION = chrome.runtime.getManifest().version;
|
|
@@ -12,33 +13,34 @@ async function getEndpoint() {
|
|
|
12
13
|
return (endpoint || DEFAULT_ENDPOINT).replace(/\/+$/, "");
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
async function
|
|
16
|
+
async function ensureInjected(tabId) {
|
|
17
|
+
const [{ result } = {}] = await chrome.scripting.executeScript({
|
|
18
|
+
target: { tabId },
|
|
19
|
+
func: () => Boolean(window.__ocAnnotationInjected),
|
|
20
|
+
});
|
|
21
|
+
if (!result) {
|
|
22
|
+
await chrome.scripting.executeScript({ target: { tabId }, files: ["overlay.js"] });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function sendToOverlay(tab, type) {
|
|
16
27
|
if (!tab?.id) return;
|
|
17
28
|
try {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const [{ result } = {}] = await chrome.scripting.executeScript({
|
|
21
|
-
target: { tabId: tab.id },
|
|
22
|
-
func: () => Boolean(window.__ocAnnotationInjected),
|
|
23
|
-
});
|
|
24
|
-
if (!result) {
|
|
25
|
-
await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ["overlay.js"] });
|
|
26
|
-
} else {
|
|
27
|
-
await chrome.tabs.sendMessage(tab.id, { type: "oc-toggle" });
|
|
28
|
-
}
|
|
29
|
+
await ensureInjected(tab.id);
|
|
30
|
+
await chrome.tabs.sendMessage(tab.id, { type });
|
|
29
31
|
} catch {
|
|
30
32
|
// e.g. chrome:// pages or the web store cannot be injected.
|
|
31
33
|
}
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
chrome.commands.onCommand.addListener(async (command) => {
|
|
35
|
-
if (command !== "toggle-overlay") return;
|
|
36
37
|
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
|
37
|
-
await
|
|
38
|
+
if (command === "select-element") await sendToOverlay(tab, "oc-pick");
|
|
39
|
+
else if (command === "toggle-sidebar") await sendToOverlay(tab, "oc-toggle-sidebar");
|
|
38
40
|
});
|
|
39
41
|
|
|
40
42
|
chrome.action.onClicked.addListener((tab) => {
|
|
41
|
-
void
|
|
43
|
+
void sendToOverlay(tab, "oc-toggle-sidebar");
|
|
42
44
|
});
|
|
43
45
|
|
|
44
46
|
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
|
@@ -66,7 +68,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
|
|
66
68
|
const res = await fetch(`${endpoint}/annotations`, {
|
|
67
69
|
method: "POST",
|
|
68
70
|
headers: { "content-type": "application/json" },
|
|
69
|
-
body: JSON.stringify({ extensionVersion: EXTENSION_VERSION, annotations }),
|
|
71
|
+
body: JSON.stringify({ extensionVersion: EXTENSION_VERSION, annotations, sessionID: msg.sessionID }),
|
|
70
72
|
});
|
|
71
73
|
const data = await res.json().catch(() => ({}));
|
|
72
74
|
if (res.ok && data.ok) {
|
package/extension/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "OpenCode Browser Annotation",
|
|
4
|
-
"version": "0.
|
|
5
|
-
"description": "Alt+A to annotate
|
|
4
|
+
"version": "0.3.1",
|
|
5
|
+
"description": "Alt+A to annotate an element, Alt+Shift+A for the annotation list. Sends element metadata to your OpenCode agent.",
|
|
6
6
|
"permissions": [
|
|
7
7
|
"activeTab",
|
|
8
8
|
"scripting",
|
|
@@ -17,16 +17,23 @@
|
|
|
17
17
|
"type": "module"
|
|
18
18
|
},
|
|
19
19
|
"action": {
|
|
20
|
-
"default_title": "
|
|
20
|
+
"default_title": "OpenCode Annotation — Alt+A to pick, Alt+Shift+A for list"
|
|
21
21
|
},
|
|
22
22
|
"options_page": "options.html",
|
|
23
23
|
"commands": {
|
|
24
|
-
"
|
|
24
|
+
"select-element": {
|
|
25
25
|
"suggested_key": {
|
|
26
26
|
"default": "Alt+A",
|
|
27
27
|
"mac": "Alt+A"
|
|
28
28
|
},
|
|
29
|
-
"description": "
|
|
29
|
+
"description": "Pick an element to annotate"
|
|
30
|
+
},
|
|
31
|
+
"toggle-sidebar": {
|
|
32
|
+
"suggested_key": {
|
|
33
|
+
"default": "Alt+Shift+A",
|
|
34
|
+
"mac": "Alt+Shift+A"
|
|
35
|
+
},
|
|
36
|
+
"description": "Toggle the annotation list sidebar"
|
|
30
37
|
}
|
|
31
38
|
}
|
|
32
39
|
}
|
package/extension/options.html
CHANGED
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
<span id="saved"></span>
|
|
38
38
|
|
|
39
39
|
<div class="hint">
|
|
40
|
-
<p><strong>
|
|
40
|
+
<p><strong>Shortcuts:</strong> <code>Alt+A</code> to pick an element, <code>Alt+Shift+A</code> to toggle the annotation list. If they do nothing after an unpacked reload, set them at <code>chrome://extensions/shortcuts</code>.</p>
|
|
41
41
|
<p>When OpenCode runs on a remote host, forward the port from your desktop and point this at the local end:</p>
|
|
42
42
|
<p><code>ssh -N -L 39517:127.0.0.1:39517 you@host</code></p>
|
|
43
43
|
</div>
|
package/extension/overlay.js
CHANGED
|
@@ -1,33 +1,29 @@
|
|
|
1
|
-
// Overlay content script
|
|
2
|
-
//
|
|
3
|
-
// Toggled via Alt+A (background command) or the toolbar icon.
|
|
1
|
+
// Overlay content script. All UI lives inside one shadow root so host-page CSS
|
|
2
|
+
// cannot interfere and ours cannot leak. Nothing pushes the page.
|
|
4
3
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
4
|
+
// Interactions:
|
|
5
|
+
// Alt+A -> start element picking (background sends "oc-pick")
|
|
6
|
+
// Alt+Shift+A -> toggle the list sidebar ("oc-toggle-sidebar")
|
|
7
|
+
// Picking an element opens a floating popup near it (onUI-style): type an
|
|
8
|
+
// instruction, choose Act/Queue, then Add (to the sidebar list) or Send (submit
|
|
9
|
+
// immediately). The sidebar is a floating rounded card holding pending
|
|
10
|
+
// annotations for batch submit.
|
|
8
11
|
|
|
9
12
|
(() => {
|
|
10
|
-
if (window.__ocAnnotationInjected)
|
|
11
|
-
window.dispatchEvent(new CustomEvent("oc-annotation-toggle"));
|
|
12
|
-
return;
|
|
13
|
-
}
|
|
13
|
+
if (window.__ocAnnotationInjected) return;
|
|
14
14
|
window.__ocAnnotationInjected = true;
|
|
15
15
|
|
|
16
|
-
const SIDEBAR_W = 348;
|
|
17
|
-
|
|
18
16
|
const ICON = {
|
|
19
17
|
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="
|
|
18
|
+
'<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="4"/><line x1="12" y1="20" x2="12" y2="23"/><line x1="1" y1="12" x2="4" y2="12"/><line x1="20" y1="12" x2="23" y2="12"/></svg>',
|
|
21
19
|
close:
|
|
22
20
|
'<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
21
|
trash:
|
|
24
22
|
'<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
23
|
send:
|
|
30
24
|
'<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>',
|
|
25
|
+
plus:
|
|
26
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>',
|
|
31
27
|
logo:
|
|
32
28
|
'<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
29
|
};
|
|
@@ -35,126 +31,117 @@
|
|
|
35
31
|
const STYLE = `
|
|
36
32
|
:host { all: initial; }
|
|
37
33
|
* { box-sizing: border-box; }
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
position: fixed; display: none; pointer-events: none; z-index: 2;
|
|
34
|
+
.oc-hl {
|
|
35
|
+
position: fixed; display: none; pointer-events: none; z-index: 2147483644;
|
|
41
36
|
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;
|
|
37
|
+
border-radius: 3px; box-shadow: 0 0 0 1px rgba(0,0,0,.4); transition: all .04s ease-out;
|
|
44
38
|
}
|
|
45
|
-
|
|
46
|
-
position: fixed; top: 18px; left:
|
|
47
|
-
transform: translateX(-50%); z-index: 3;
|
|
39
|
+
.oc-hint {
|
|
40
|
+
position: fixed; top: 18px; left: 50%; transform: translateX(-50%); z-index: 2147483646;
|
|
48
41
|
display: none; gap: 10px; align-items: center; pointer-events: none;
|
|
49
42
|
font: 13px system-ui, sans-serif; color: #e6e8ee;
|
|
50
|
-
background: rgba(20,
|
|
51
|
-
box-shadow: 0 8px 24px rgba(0,0,0,.
|
|
43
|
+
background: rgba(18,20,28,.95); padding: 8px 14px; border-radius: 999px;
|
|
44
|
+
box-shadow: 0 8px 24px rgba(0,0,0,.45); border: 1px solid rgba(255,255,255,.08);
|
|
52
45
|
}
|
|
53
|
-
|
|
46
|
+
.oc-hint .key { font-size: 11px; background: rgba(255,255,255,.14); padding: 2px 7px; border-radius: 5px; }
|
|
54
47
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
48
|
+
/* shared surface */
|
|
49
|
+
.oc-surface {
|
|
50
|
+
background: rgba(20,22,30,.98); color: #e6e8ee; border: 1px solid rgba(255,255,255,.1);
|
|
51
|
+
border-radius: 14px; box-shadow: 0 18px 50px rgba(0,0,0,.5);
|
|
59
52
|
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
53
|
}
|
|
106
|
-
.oc-
|
|
107
|
-
|
|
108
|
-
|
|
54
|
+
.oc-head { display: flex; align-items: center; gap: 9px; padding: 11px 12px; border-bottom: 1px solid rgba(255,255,255,.07); }
|
|
55
|
+
.oc-head .logo { width: 17px; height: 17px; color: #4c8dff; flex: none; }
|
|
56
|
+
.oc-head .title { font-weight: 650; font-size: 13px; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
57
|
+
.oc-path { font: 11px ui-monospace, monospace; color: #8b90a0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
58
|
+
.iconbtn { display: inline-flex; align-items: center; justify-content: center; border: none; background: transparent; color: #8b90a0; cursor: pointer; width: 28px; height: 28px; border-radius: 8px; padding: 0; }
|
|
59
|
+
.iconbtn svg { width: 15px; height: 15px; }
|
|
60
|
+
.iconbtn:hover { background: rgba(255,255,255,.08); color: #e6e8ee; }
|
|
61
|
+
|
|
62
|
+
textarea.oc-ta {
|
|
63
|
+
width: 100%; resize: vertical; min-height: 62px; font: inherit;
|
|
64
|
+
border: 1px solid rgba(255,255,255,.12); border-radius: 9px; padding: 9px 10px;
|
|
109
65
|
background: #14151b; color: #e6e8ee;
|
|
110
66
|
}
|
|
111
|
-
.oc-
|
|
112
|
-
.oc-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
.
|
|
121
|
-
.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
67
|
+
textarea.oc-ta::placeholder { color: #616678; }
|
|
68
|
+
textarea.oc-ta:focus { outline: none; border-color: #4c8dff; box-shadow: 0 0 0 3px rgba(76,141,255,.18); }
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
.btn { font: inherit; font-weight: 600; font-size: 12.5px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; padding: 8px 12px; border-radius: 9px; cursor: pointer; border: 1px solid rgba(255,255,255,.14); background: rgba(255,255,255,.05); color: #e6e8ee; transition: background .15s, transform .05s; }
|
|
72
|
+
.btn svg { width: 15px; height: 15px; }
|
|
73
|
+
.btn:hover:not(:disabled) { background: rgba(255,255,255,.1); }
|
|
74
|
+
.btn:active:not(:disabled) { transform: translateY(1px); }
|
|
75
|
+
.btn:disabled { opacity: .4; cursor: default; }
|
|
76
|
+
.btn.primary { background: #3f7dff; border-color: #3f7dff; color: #fff; }
|
|
77
|
+
.btn.primary:hover:not(:disabled) { background: #3670f0; }
|
|
78
|
+
|
|
79
|
+
/* popup */
|
|
80
|
+
.oc-popup { position: fixed; z-index: 2147483647; width: 320px; pointer-events: auto; }
|
|
81
|
+
.oc-popup .oc-body { padding: 11px 12px; }
|
|
82
|
+
.oc-popup .oc-foot { display: flex; gap: 8px; padding: 10px 12px; border-top: 1px solid rgba(255,255,255,.07); }
|
|
83
|
+
.oc-popup .oc-foot .btn { flex: 1; }
|
|
84
|
+
.oc-hintline { font-size: 11px; color: #6a6f80; margin-top: 8px; text-align: center; }
|
|
85
|
+
|
|
86
|
+
/* sidebar (floating card at right, not pinned) */
|
|
87
|
+
.oc-sidebar { position: fixed; top: 16px; right: 16px; bottom: 16px; width: 330px; z-index: 2147483645; display: flex; flex-direction: column; pointer-events: auto; overflow: hidden; }
|
|
88
|
+
.oc-sidebar .badge { font-size: 11px; font-weight: 700; color: #7aa9ff; background: rgba(76,141,255,.16); border-radius: 999px; padding: 1px 8px; }
|
|
89
|
+
.oc-list { flex: 1; overflow-y: auto; padding: 10px 12px; display: flex; flex-direction: column; gap: 9px; }
|
|
90
|
+
.oc-list::-webkit-scrollbar { width: 10px; }
|
|
91
|
+
.oc-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,.12); border-radius: 6px; border: 3px solid transparent; background-clip: content-box; }
|
|
92
|
+
.oc-empty { color: #71768a; font-size: 12.5px; padding: 24px 8px; text-align: center; line-height: 1.6; }
|
|
93
|
+
.oc-card { background: rgba(255,255,255,.03); border: 1px solid rgba(255,255,255,.08); border-radius: 11px; padding: 9px 10px; }
|
|
94
|
+
.oc-card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; }
|
|
95
|
+
.oc-desc { font: 11px ui-monospace, monospace; color: #aeb4c6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; background: rgba(255,255,255,.06); padding: 2px 7px; border-radius: 6px; flex: 1; }
|
|
96
|
+
.oc-card .oc-text { font-size: 12.5px; color: #d4d7e0; white-space: pre-wrap; word-break: break-word; }
|
|
97
|
+
.oc-foot-bar { padding: 11px 12px; border-top: 1px solid rgba(255,255,255,.07); position: relative; }
|
|
98
|
+
.oc-target-row { display: flex; align-items: center; gap: 8px; margin-bottom: 9px; }
|
|
99
|
+
.oc-target-label { font-size: 11px; font-weight: 600; color: #8b90a0; flex: none; }
|
|
100
|
+
.oc-select { flex: 1; min-width: 0; font: inherit; font-size: 12px; color: #e6e8ee; background: #14151b; border: 1px solid rgba(255,255,255,.12); border-radius: 8px; padding: 6px 8px; }
|
|
101
|
+
.oc-select:focus { outline: none; border-color: #4c8dff; }
|
|
125
102
|
.oc-status { font-size: 12px; margin-bottom: 9px; display: flex; align-items: center; gap: 8px; color: #9298aa; }
|
|
126
103
|
.oc-status::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: #5b6070; flex: none; }
|
|
127
104
|
.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
|
|
130
|
-
.oc-
|
|
131
|
-
.oc-
|
|
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
|
-
}
|
|
105
|
+
.oc-status.warn::before { background: #fbbf24; } .oc-status.bad::before { background: #f87171; } .oc-status.checking::before { background: #60a5fa; }
|
|
106
|
+
.oc-status.good { color: #34d399; } .oc-status.warn { color: #fbbf24; } .oc-status.bad { color: #f87171; }
|
|
107
|
+
.oc-submit { width: 100%; }
|
|
108
|
+
.oc-toast { position: absolute; left: 12px; right: 12px; bottom: 58px; background: #0e0f14; color: #fff; font-size: 12.5px; padding: 9px 12px; border-radius: 9px; opacity: 0; transform: translateY(6px); transition: opacity .2s, transform .2s; pointer-events: none; box-shadow: 0 10px 30px rgba(0,0,0,.5); border: 1px solid rgba(255,255,255,.08); }
|
|
142
109
|
.oc-toast.show { opacity: 1; transform: translateY(0); }
|
|
143
110
|
.oc-toast.bad { border-color: rgba(248,113,113,.5); color: #fca5a5; }
|
|
111
|
+
.oc-toast-float { position: fixed; left: auto; right: 20px; bottom: 20px; width: auto; max-width: 320px; z-index: 2147483647; }
|
|
144
112
|
`;
|
|
145
113
|
|
|
146
114
|
let host = null;
|
|
147
115
|
let root = null;
|
|
148
116
|
let picking = false;
|
|
149
|
-
let
|
|
117
|
+
let popupEl = null;
|
|
118
|
+
let pending = []; // annotations added to the sidebar list
|
|
119
|
+
let sidebarOpen = false;
|
|
150
120
|
let statusTimer = null;
|
|
151
|
-
let
|
|
121
|
+
let sessions = []; // [{ id, title, updated }]
|
|
122
|
+
let targetSessionID = null; // user-chosen target; null = auto (last active)
|
|
123
|
+
let autoSessionID = null; // the plugin's last-active session
|
|
124
|
+
|
|
125
|
+
function effectiveTarget() {
|
|
126
|
+
return targetSessionID || autoSessionID;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function targetTitle() {
|
|
130
|
+
const id = effectiveTarget();
|
|
131
|
+
const s = sessions.find((x) => x.id === id);
|
|
132
|
+
return s ? s.title : null;
|
|
133
|
+
}
|
|
152
134
|
|
|
153
|
-
|
|
135
|
+
function targetLabel() {
|
|
136
|
+
const t = targetTitle();
|
|
137
|
+
return t ? ` · → ${escapeHtml(t)}` : "";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------- metadata ----------
|
|
154
141
|
|
|
155
142
|
function bestTestId(el) {
|
|
156
|
-
for (const
|
|
157
|
-
const v = el.getAttribute && el.getAttribute(
|
|
143
|
+
for (const a of ["data-testid", "data-test", "data-test-id", "data-cy", "data-qa"]) {
|
|
144
|
+
const v = el.getAttribute && el.getAttribute(a);
|
|
158
145
|
if (v) return v;
|
|
159
146
|
}
|
|
160
147
|
return undefined;
|
|
@@ -170,9 +157,9 @@
|
|
|
170
157
|
if (node.classList && node.classList.length) {
|
|
171
158
|
sel += "." + Array.from(node.classList).slice(0, 3).map((c) => CSS.escape(c)).join(".");
|
|
172
159
|
}
|
|
173
|
-
const
|
|
174
|
-
if (
|
|
175
|
-
const sibs = Array.from(
|
|
160
|
+
const p = node.parentElement;
|
|
161
|
+
if (p) {
|
|
162
|
+
const sibs = Array.from(p.children).filter((c) => c.nodeName === node.nodeName);
|
|
176
163
|
if (sibs.length > 1) sel += `:nth-of-type(${sibs.indexOf(node) + 1})`;
|
|
177
164
|
}
|
|
178
165
|
parts.unshift(sel);
|
|
@@ -188,7 +175,7 @@
|
|
|
188
175
|
function elementMeta(el, inShadow) {
|
|
189
176
|
const r = el.getBoundingClientRect();
|
|
190
177
|
const classes = el.classList ? Array.from(el.classList) : [];
|
|
191
|
-
const
|
|
178
|
+
const m = {
|
|
192
179
|
selector: cssPath(el),
|
|
193
180
|
tag: el.tagName,
|
|
194
181
|
id: el.id || undefined,
|
|
@@ -205,61 +192,48 @@
|
|
|
205
192
|
inIframe: window.top !== window.self,
|
|
206
193
|
html: (el.outerHTML || "").slice(0, 800),
|
|
207
194
|
};
|
|
208
|
-
for (const k of Object.keys(
|
|
209
|
-
return
|
|
195
|
+
for (const k of Object.keys(m)) if (m[k] === undefined) delete m[k];
|
|
196
|
+
return m;
|
|
210
197
|
}
|
|
211
198
|
|
|
212
|
-
function
|
|
199
|
+
function descriptor(el) {
|
|
213
200
|
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
|
-
: "";
|
|
201
|
+
const id = el.testId ? `[data-testid=${el.testId}]` : el.id ? `#${el.id}` : el.ariaLabel ? `[${el.ariaLabel}]` : el.classes && el.classes.length ? `.${el.classes[0]}` : "";
|
|
223
202
|
return `${tag}${id}`;
|
|
224
203
|
}
|
|
225
204
|
|
|
226
|
-
|
|
227
|
-
|
|
205
|
+
function escapeHtml(s) {
|
|
206
|
+
return String(s || "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---------- picking ----------
|
|
228
210
|
|
|
229
211
|
function pickTarget(e) {
|
|
230
212
|
const path = e.composedPath ? e.composedPath() : [e.target];
|
|
231
213
|
for (const n of path) {
|
|
232
|
-
if (n === host) return null;
|
|
214
|
+
if (n === host) return null;
|
|
233
215
|
if (!(n instanceof Element)) continue;
|
|
234
|
-
if (root && root.contains(n)) return null;
|
|
216
|
+
if (root && root.contains(n)) return null;
|
|
235
217
|
const inShadow = n.getRootNode && n.getRootNode() instanceof ShadowRoot;
|
|
236
218
|
return { el: n, inShadow };
|
|
237
219
|
}
|
|
238
220
|
return null;
|
|
239
221
|
}
|
|
240
222
|
|
|
241
|
-
function
|
|
242
|
-
|
|
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
|
-
});
|
|
223
|
+
function hl() {
|
|
224
|
+
return root.getElementById("oc-hl");
|
|
252
225
|
}
|
|
253
226
|
|
|
254
227
|
function onMove(e) {
|
|
255
228
|
if (!picking) return;
|
|
256
229
|
const hit = pickTarget(e);
|
|
230
|
+
const box = hl();
|
|
257
231
|
if (!hit) {
|
|
258
|
-
|
|
259
|
-
if (box) box.style.display = "none";
|
|
232
|
+
box.style.display = "none";
|
|
260
233
|
return;
|
|
261
234
|
}
|
|
262
|
-
|
|
235
|
+
const r = hit.el.getBoundingClientRect();
|
|
236
|
+
Object.assign(box.style, { display: "block", left: `${r.left}px`, top: `${r.top}px`, width: `${r.width}px`, height: `${r.height}px` });
|
|
263
237
|
}
|
|
264
238
|
|
|
265
239
|
function onClick(e) {
|
|
@@ -269,94 +243,234 @@
|
|
|
269
243
|
e.preventDefault();
|
|
270
244
|
e.stopPropagation();
|
|
271
245
|
e.stopImmediatePropagation();
|
|
246
|
+
const rect = hit.el.getBoundingClientRect();
|
|
272
247
|
stopPicking();
|
|
273
|
-
|
|
248
|
+
openPopup(elementMeta(hit.el, hit.inShadow), rect);
|
|
274
249
|
}
|
|
275
250
|
|
|
276
251
|
function onKey(e) {
|
|
277
252
|
if (e.key === "Escape") {
|
|
278
|
-
if (
|
|
279
|
-
else
|
|
253
|
+
if (popupEl) closePopup();
|
|
254
|
+
else if (picking) stopPicking();
|
|
280
255
|
}
|
|
281
256
|
}
|
|
282
257
|
|
|
258
|
+
function ensureUI() {
|
|
259
|
+
if (host) return;
|
|
260
|
+
host = document.createElement("div");
|
|
261
|
+
host.id = "oc-annotation-host";
|
|
262
|
+
host.style.cssText = "all: initial;";
|
|
263
|
+
root = host.attachShadow({ mode: "open" });
|
|
264
|
+
const style = document.createElement("style");
|
|
265
|
+
style.textContent = STYLE;
|
|
266
|
+
root.appendChild(style);
|
|
267
|
+
const hlBox = document.createElement("div");
|
|
268
|
+
hlBox.className = "oc-hl";
|
|
269
|
+
hlBox.id = "oc-hl";
|
|
270
|
+
root.appendChild(hlBox);
|
|
271
|
+
const hint = document.createElement("div");
|
|
272
|
+
hint.className = "oc-hint";
|
|
273
|
+
hint.id = "oc-hint";
|
|
274
|
+
hint.innerHTML = `<span>Click an element to annotate</span><span class="key">Esc</span>`;
|
|
275
|
+
root.appendChild(hint);
|
|
276
|
+
document.documentElement.appendChild(host);
|
|
277
|
+
document.addEventListener("mousemove", onMove, true);
|
|
278
|
+
document.addEventListener("click", onClick, true);
|
|
279
|
+
document.addEventListener("keydown", onKey, true);
|
|
280
|
+
}
|
|
281
|
+
|
|
283
282
|
function startPicking() {
|
|
283
|
+
ensureUI();
|
|
284
|
+
closePopup();
|
|
284
285
|
picking = true;
|
|
285
286
|
document.documentElement.style.cursor = "crosshair";
|
|
286
287
|
root.getElementById("oc-hint").style.display = "flex";
|
|
287
|
-
setPickBtn(true);
|
|
288
288
|
}
|
|
289
289
|
|
|
290
290
|
function stopPicking() {
|
|
291
291
|
picking = false;
|
|
292
292
|
document.documentElement.style.cursor = "";
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
293
|
+
if (root) {
|
|
294
|
+
hl().style.display = "none";
|
|
295
|
+
root.getElementById("oc-hint").style.display = "none";
|
|
296
|
+
}
|
|
297
297
|
}
|
|
298
298
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
299
|
+
// ---------- popup (onUI-style, positioned near element) ----------
|
|
300
|
+
|
|
301
|
+
function closePopup() {
|
|
302
|
+
if (popupEl) {
|
|
303
|
+
popupEl.remove();
|
|
304
|
+
popupEl = null;
|
|
304
305
|
}
|
|
305
306
|
}
|
|
306
307
|
|
|
307
|
-
|
|
308
|
+
function openPopup(element, rect) {
|
|
309
|
+
ensureUI();
|
|
310
|
+
closePopup();
|
|
311
|
+
const el = document.createElement("div");
|
|
312
|
+
el.className = "oc-popup oc-surface";
|
|
313
|
+
el.innerHTML = `
|
|
314
|
+
<div class="oc-head">
|
|
315
|
+
<span class="logo">${ICON.logo}</span>
|
|
316
|
+
<span class="oc-path" title="${escapeHtml(element.selector || "")}">${escapeHtml(descriptor(element))}</span>
|
|
317
|
+
<button class="iconbtn oc-x" title="Cancel (Esc)">${ICON.close}</button>
|
|
318
|
+
</div>
|
|
319
|
+
<div class="oc-body">
|
|
320
|
+
<textarea class="oc-ta" rows="3" placeholder="Describe the change or ask about this element…"></textarea>
|
|
321
|
+
<div class="oc-hintline">Cmd/Ctrl+Enter to send${targetLabel()}</div>
|
|
322
|
+
</div>
|
|
323
|
+
<div class="oc-foot">
|
|
324
|
+
<button class="btn oc-add">${ICON.plus}<span>Add to list</span></button>
|
|
325
|
+
<button class="btn primary oc-send">${ICON.send}<span>Send</span></button>
|
|
326
|
+
</div>`;
|
|
327
|
+
root.appendChild(el);
|
|
328
|
+
popupEl = el;
|
|
329
|
+
positionPopup(el, rect);
|
|
330
|
+
|
|
331
|
+
const ta = el.querySelector(".oc-ta");
|
|
332
|
+
setTimeout(() => ta.focus(), 30);
|
|
333
|
+
|
|
334
|
+
el.querySelector(".oc-x").addEventListener("click", closePopup);
|
|
335
|
+
const build = () => ({ instruction: ta.value.trim(), page: { url: location.href, title: document.title }, element });
|
|
336
|
+
el.querySelector(".oc-add").addEventListener("click", () => {
|
|
337
|
+
if (!ta.value.trim()) return ta.focus();
|
|
338
|
+
pending.push(build());
|
|
339
|
+
closePopup();
|
|
340
|
+
openSidebar();
|
|
341
|
+
});
|
|
342
|
+
el.querySelector(".oc-send").addEventListener("click", () => {
|
|
343
|
+
if (!ta.value.trim()) return ta.focus();
|
|
344
|
+
const one = build();
|
|
345
|
+
closePopup();
|
|
346
|
+
submit([one], true);
|
|
347
|
+
});
|
|
348
|
+
ta.addEventListener("keydown", (e) => {
|
|
349
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
|
350
|
+
e.preventDefault();
|
|
351
|
+
if (ta.value.trim()) {
|
|
352
|
+
const one = build();
|
|
353
|
+
closePopup();
|
|
354
|
+
submit([one], true);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function positionPopup(el, rect) {
|
|
361
|
+
const w = 320;
|
|
362
|
+
const r = el.getBoundingClientRect();
|
|
363
|
+
const h = r.height || 220;
|
|
364
|
+
let left = rect.left + rect.width + 14;
|
|
365
|
+
let top = rect.top;
|
|
366
|
+
if (left + w > window.innerWidth - 16) left = rect.left - w - 14;
|
|
367
|
+
if (left < 16) left = 16;
|
|
368
|
+
if (top + h > window.innerHeight - 16) top = window.innerHeight - h - 16;
|
|
369
|
+
if (top < 16) top = 16;
|
|
370
|
+
el.style.left = `${left}px`;
|
|
371
|
+
el.style.top = `${top}px`;
|
|
372
|
+
}
|
|
308
373
|
|
|
309
|
-
|
|
310
|
-
|
|
374
|
+
// ---------- sidebar ----------
|
|
375
|
+
|
|
376
|
+
function openSidebar() {
|
|
377
|
+
ensureUI();
|
|
378
|
+
sidebarOpen = true;
|
|
379
|
+
let sb = root.getElementById("oc-sidebar");
|
|
380
|
+
if (!sb) {
|
|
381
|
+
sb = document.createElement("aside");
|
|
382
|
+
sb.className = "oc-sidebar oc-surface";
|
|
383
|
+
sb.id = "oc-sidebar";
|
|
384
|
+
sb.innerHTML = `
|
|
385
|
+
<div class="oc-head">
|
|
386
|
+
<span class="logo">${ICON.logo}</span>
|
|
387
|
+
<span class="title">Annotations</span>
|
|
388
|
+
<span class="badge" id="oc-badge">0</span>
|
|
389
|
+
<button class="iconbtn" id="oc-close" title="Close (Alt+Shift+A)">${ICON.close}</button>
|
|
390
|
+
</div>
|
|
391
|
+
<div class="oc-list" id="oc-list"></div>
|
|
392
|
+
<div class="oc-foot-bar">
|
|
393
|
+
<label class="oc-target-row">
|
|
394
|
+
<span class="oc-target-label">Session</span>
|
|
395
|
+
<select class="oc-select" id="oc-session"></select>
|
|
396
|
+
</label>
|
|
397
|
+
<div class="oc-status" id="oc-status">…</div>
|
|
398
|
+
<button class="btn primary oc-submit" id="oc-submit" disabled>${ICON.send}<span>Submit to agent</span></button>
|
|
399
|
+
<div class="oc-toast" id="oc-toast"></div>
|
|
400
|
+
</div>`;
|
|
401
|
+
root.appendChild(sb);
|
|
402
|
+
sb.querySelector("#oc-close").addEventListener("click", closeSidebar);
|
|
403
|
+
sb.querySelector("#oc-session").addEventListener("change", (e) => {
|
|
404
|
+
targetSessionID = e.target.value || null;
|
|
405
|
+
});
|
|
406
|
+
sb.querySelector("#oc-submit").addEventListener("click", () => {
|
|
407
|
+
submit(pending, false);
|
|
408
|
+
});
|
|
409
|
+
sb.querySelector("#oc-list").addEventListener("click", (e) => {
|
|
410
|
+
const rm = e.target.closest(".oc-rm");
|
|
411
|
+
if (rm) {
|
|
412
|
+
pending.splice(Number(rm.dataset.i), 1);
|
|
413
|
+
renderCards();
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
sb.style.display = "flex";
|
|
311
418
|
renderCards();
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
if (last) last.focus();
|
|
419
|
+
refreshStatus();
|
|
420
|
+
if (!statusTimer) statusTimer = setInterval(refreshStatus, 15000);
|
|
315
421
|
}
|
|
316
422
|
|
|
317
|
-
function
|
|
318
|
-
|
|
423
|
+
function closeSidebar() {
|
|
424
|
+
sidebarOpen = false;
|
|
425
|
+
const sb = root && root.getElementById("oc-sidebar");
|
|
426
|
+
if (sb) sb.style.display = "none";
|
|
427
|
+
if (statusTimer) {
|
|
428
|
+
clearInterval(statusTimer);
|
|
429
|
+
statusTimer = null;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function toggleSidebar() {
|
|
434
|
+
if (sidebarOpen) closeSidebar();
|
|
435
|
+
else openSidebar();
|
|
319
436
|
}
|
|
320
437
|
|
|
321
438
|
function renderCards() {
|
|
439
|
+
if (!root) return;
|
|
322
440
|
const list = root.getElementById("oc-list");
|
|
441
|
+
const badge = root.getElementById("oc-badge");
|
|
442
|
+
if (badge) badge.textContent = String(pending.length);
|
|
323
443
|
list.innerHTML = "";
|
|
324
|
-
if (
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
list.appendChild(
|
|
444
|
+
if (pending.length === 0) {
|
|
445
|
+
const e = document.createElement("div");
|
|
446
|
+
e.className = "oc-empty";
|
|
447
|
+
e.textContent = "No annotations yet. Press Alt+A, click an element, and “Add to list”.";
|
|
448
|
+
list.appendChild(e);
|
|
329
449
|
}
|
|
330
|
-
|
|
450
|
+
pending.forEach((a, i) => {
|
|
331
451
|
const card = document.createElement("div");
|
|
332
452
|
card.className = "oc-card";
|
|
333
453
|
card.innerHTML = `
|
|
334
454
|
<div class="oc-card-head">
|
|
335
|
-
<span class="oc-desc" title="${escapeHtml(a.element.selector || "")}">${escapeHtml(
|
|
455
|
+
<span class="oc-desc" title="${escapeHtml(a.element.selector || "")}">${escapeHtml(descriptor(a.element))}</span>
|
|
336
456
|
<button class="iconbtn oc-rm" data-i="${i}" title="Remove">${ICON.trash}</button>
|
|
337
457
|
</div>
|
|
338
|
-
<
|
|
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>`;
|
|
458
|
+
<div class="oc-text">${escapeHtml(a.instruction || "(no instruction)")}</div>`;
|
|
343
459
|
list.appendChild(card);
|
|
344
460
|
});
|
|
345
|
-
updateSubmit();
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
function updateSubmit() {
|
|
349
461
|
const btn = root.getElementById("oc-submit");
|
|
350
|
-
btn
|
|
351
|
-
|
|
352
|
-
? `Submit ${
|
|
353
|
-
|
|
462
|
+
if (btn) {
|
|
463
|
+
btn.disabled = pending.length === 0;
|
|
464
|
+
btn.querySelector("span").textContent = pending.length ? `Submit ${pending.length} to agent` : "Submit to agent";
|
|
465
|
+
}
|
|
354
466
|
}
|
|
355
467
|
|
|
356
|
-
// ---------- status ----------
|
|
468
|
+
// ---------- status + submit ----------
|
|
357
469
|
|
|
358
470
|
function refreshStatus() {
|
|
471
|
+
if (!root) return;
|
|
359
472
|
const el = root.getElementById("oc-status");
|
|
473
|
+
if (!el) return;
|
|
360
474
|
el.className = "oc-status checking";
|
|
361
475
|
el.textContent = "Checking connection…";
|
|
362
476
|
chrome.runtime.sendMessage({ type: "oc-status" }, (res) => {
|
|
@@ -366,10 +480,12 @@
|
|
|
366
480
|
return;
|
|
367
481
|
}
|
|
368
482
|
if (res.ok && res.data?.ok) {
|
|
369
|
-
|
|
370
|
-
|
|
483
|
+
sessions = Array.isArray(res.data.sessions) ? res.data.sessions : [];
|
|
484
|
+
autoSessionID = res.data.sessionID || null;
|
|
485
|
+
renderSessions();
|
|
486
|
+
if (sessions.length || res.data.activeSession) {
|
|
371
487
|
el.className = "oc-status good";
|
|
372
|
-
el.textContent =
|
|
488
|
+
el.textContent = "Connected";
|
|
373
489
|
} else {
|
|
374
490
|
el.className = "oc-status warn";
|
|
375
491
|
el.textContent = "Connected — send a message in OpenCode first";
|
|
@@ -381,141 +497,79 @@
|
|
|
381
497
|
});
|
|
382
498
|
}
|
|
383
499
|
|
|
384
|
-
|
|
500
|
+
function renderSessions() {
|
|
501
|
+
if (!root) return;
|
|
502
|
+
const sel = root.getElementById("oc-session");
|
|
503
|
+
if (!sel) return;
|
|
504
|
+
// If the chosen target vanished, fall back to auto.
|
|
505
|
+
if (targetSessionID && !sessions.some((s) => s.id === targetSessionID)) targetSessionID = null;
|
|
506
|
+
const current = effectiveTarget();
|
|
507
|
+
const opts = [`<option value="">Auto (last active)</option>`].concat(
|
|
508
|
+
sessions.map((s) => {
|
|
509
|
+
const active = s.id === autoSessionID ? " • active" : "";
|
|
510
|
+
const label = `${(s.title || s.id).slice(0, 40)}${active}`;
|
|
511
|
+
const selected = s.id === current ? " selected" : "";
|
|
512
|
+
return `<option value="${escapeHtml(s.id)}"${selected}>${escapeHtml(label)}</option>`;
|
|
513
|
+
}),
|
|
514
|
+
);
|
|
515
|
+
// Keep "Auto" selected when no explicit target.
|
|
516
|
+
sel.innerHTML = opts.join("");
|
|
517
|
+
if (!targetSessionID) sel.value = "";
|
|
518
|
+
}
|
|
385
519
|
|
|
386
|
-
function
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
520
|
+
function toast(text, bad) {
|
|
521
|
+
if (!root) return;
|
|
522
|
+
// Prefer the sidebar's toast when it is open; otherwise use a standalone
|
|
523
|
+
// floating toast so a quick Send never forces the sidebar open.
|
|
524
|
+
const sb = root.getElementById("oc-sidebar");
|
|
525
|
+
let t = sb && sb.style.display !== "none" ? root.getElementById("oc-toast") : root.getElementById("oc-toast-float");
|
|
526
|
+
if (!t) {
|
|
527
|
+
t = document.createElement("div");
|
|
528
|
+
t.className = "oc-toast oc-toast-float";
|
|
529
|
+
t.id = "oc-toast-float";
|
|
530
|
+
root.appendChild(t);
|
|
531
|
+
}
|
|
532
|
+
t.textContent = text;
|
|
533
|
+
t.className = (t.id === "oc-toast-float" ? "oc-toast oc-toast-float" : "oc-toast") + " show" + (bad ? " bad" : "");
|
|
534
|
+
clearTimeout(t.__timer);
|
|
535
|
+
t.__timer = setTimeout(() => {
|
|
536
|
+
t.className = t.id === "oc-toast-float" ? "oc-toast oc-toast-float" : "oc-toast";
|
|
537
|
+
}, 4000);
|
|
391
538
|
}
|
|
392
539
|
|
|
393
|
-
function submit() {
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
setToast("Submitting…");
|
|
399
|
-
chrome.runtime.sendMessage({ type: "oc-submit", annotations }, (res) => {
|
|
540
|
+
function submit(annotations, quick) {
|
|
541
|
+
if (!annotations.length) return;
|
|
542
|
+
toast("Submitting…");
|
|
543
|
+
const sessionID = effectiveTarget() || undefined;
|
|
544
|
+
chrome.runtime.sendMessage({ type: "oc-submit", annotations, sessionID }, (res) => {
|
|
400
545
|
if (chrome.runtime.lastError || !res) {
|
|
401
|
-
|
|
402
|
-
updateSubmit();
|
|
546
|
+
toast("Extension error", true);
|
|
403
547
|
return;
|
|
404
548
|
}
|
|
405
549
|
if (res.ok) {
|
|
406
550
|
const parts = [];
|
|
407
551
|
if (res.injected) parts.push(`${res.injected} sent`);
|
|
408
552
|
if (res.queued) parts.push(`${res.queued} queued`);
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
553
|
+
toast(parts.length ? parts.join(", ") : "Submitted");
|
|
554
|
+
if (!quick) {
|
|
555
|
+
pending = [];
|
|
556
|
+
renderCards();
|
|
557
|
+
}
|
|
412
558
|
refreshStatus();
|
|
413
559
|
} else {
|
|
414
|
-
|
|
415
|
-
|
|
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();
|
|
560
|
+
toast(`Failed: ${res.error || "unknown"}`, true);
|
|
561
|
+
if (!quick) {
|
|
562
|
+
// keep the list so the user can retry
|
|
563
|
+
renderCards();
|
|
564
|
+
}
|
|
470
565
|
}
|
|
471
566
|
});
|
|
472
|
-
|
|
473
|
-
renderCards();
|
|
474
|
-
refreshStatus();
|
|
475
567
|
}
|
|
476
568
|
|
|
477
|
-
|
|
478
|
-
const el = document.documentElement;
|
|
479
|
-
el.style.transition = "margin-right .22s ease";
|
|
480
|
-
el.style.marginRight = on ? `${SIDEBAR_W}px` : "";
|
|
481
|
-
}
|
|
569
|
+
// ---------- messages ----------
|
|
482
570
|
|
|
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
571
|
chrome.runtime.onMessage.addListener((msg) => {
|
|
515
|
-
if (msg?.type === "oc-
|
|
572
|
+
if (msg?.type === "oc-pick") startPicking();
|
|
573
|
+
else if (msg?.type === "oc-toggle-sidebar") toggleSidebar();
|
|
516
574
|
});
|
|
517
|
-
|
|
518
|
-
// First injection opens immediately.
|
|
519
|
-
visible = true;
|
|
520
|
-
open();
|
|
521
575
|
})();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-browser-annotation-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|