Skills › Compound Engineering › ce-prototype › annotate.js
annotate.js
837 lines · skills/ce-prototype/assets/annotate.js at 3449230
1(() => {
2 const token = new URLSearchParams(window.location.search).get("token")
3 // The helper stamps the path it served on this script. History API rewrites
4 // change location.pathname without changing which screen file this is.
5 const servedPage = document.currentScript?.getAttribute("data-ce-page") || "/"
6 const servedDocument = document.currentScript?.getAttribute("data-ce-document") || ""
7 // Overlay host hangs off <html>, not <body>, so it stays out of body layout
8 // and document.body.children. A manual popover puts it on the top layer so
9 // Annotate and the end control stay usable over dialog.showModal().
10 const host = document.createElement("ce-annotate-host")
11 host.style.cssText =
12 "display: block; position: fixed; inset: 0; width: auto; height: auto; overflow: visible; color: inherit; background: transparent; pointer-events: none; z-index: 2147483645; margin: 0; padding: 0; border: 0;"
13 document.documentElement.appendChild(host)
14 host.setAttribute("popover", "manual")
15 const raiseOverlay = () => {
16 if (typeof host.showPopover !== "function") return
17 try { host.hidePopover() } catch {}
18 try { host.showPopover() } catch {}
19 }
20 raiseOverlay()
21 if (typeof MutationObserver === "function") {
22 new MutationObserver((records) => {
23 for (const record of records) {
24 const nodes = record.type === "attributes" ? [record.target] : record.addedNodes
25 for (const node of nodes) {
26 if (node !== host && node.nodeName === "DIALOG" && node.hasAttribute?.("open")) {
27 raiseOverlay()
28 return
29 }
30 }
31 }
32 }).observe(document.documentElement, { subtree: true, childList: true, attributes: true, attributeFilter: ["open"] })
33 }
34 const shadow = host.attachShadow({ mode: "open" })
35 const css = document.createElement("link")
36 css.rel = "stylesheet"
37 // Resolved against this script's own URL, not the document base a screen may set.
38 css.href = new URL("/__ce-annotate/annotate.css", document.currentScript?.src || window.location.origin).href
39 shadow.appendChild(css)
41 const chrome = document.createElement("div")
42 chrome.className = "ce-annotate-chrome"
43 chrome.innerHTML = `
44 <button type="button" class="ce-annotate-toggle" aria-pressed="false" aria-keyshortcuts="Control+A Escape" title="Click pins the rest state. Ctrl+A while hovering freezes hover, then click to pin. Esc or Ctrl+A again turns it off."><span class="ce-annotate-toggle-label">Annotate</span><kbd class="ce-annotate-hotkey">Ctrl+A</kbd></button>
45 <button type="button" class="ce-annotate-stop" title="Close the preview and return to chat">End session</button>
46 <span class="ce-annotate-status" hidden></span>
47 `
48 shadow.appendChild(chrome)
50 const catcher = document.createElement("div")
51 catcher.className = "ce-annotate-catcher"
52 catcher.hidden = true
53 shadow.appendChild(catcher)
55 const layer = document.createElement("div")
56 layer.className = "ce-annotate-layer"
57 shadow.appendChild(layer)
59 const pinTip = document.createElement("div")
60 pinTip.className = "ce-annotate-pin-tip"
61 pinTip.hidden = true
62 shadow.appendChild(pinTip)
64 const composer = document.createElement("form")
65 composer.className = "ce-annotate-composer"
66 composer.hidden = true
67 composer.innerHTML = `
68 <textarea class="ce-annotate-comment" rows="3" placeholder="What should change here?"></textarea>
69 <div class="ce-annotate-actions">
70 <button type="submit" class="ce-annotate-submit" disabled>Submit</button>
71 <button type="button" class="ce-annotate-cancel">Cancel</button>
72 </div>
73 <p class="ce-annotate-error" hidden></p>
74 `
75 shadow.appendChild(composer)
77 const toggle = chrome.querySelector(".ce-annotate-toggle")
78 const toggleLabel = chrome.querySelector(".ce-annotate-toggle-label")
79 const stop = chrome.querySelector(".ce-annotate-stop")
80 const status = chrome.querySelector(".ce-annotate-status")
81 const commentField = composer.querySelector(".ce-annotate-comment")
82 const submit = composer.querySelector(".ce-annotate-submit")
83 const cancel = composer.querySelector(".ce-annotate-cancel")
84 const error = composer.querySelector(".ce-annotate-error")
86 const overlaySession = document.currentScript?.getAttribute("data-ce-session") || ""
87 const STATE_KEY = overlaySession ? `ce-annotate-state:${overlaySession}` : "ce-annotate-state"
88 const PIN_STATUS = { held: "pending", queued: "pending", working: "working" }
89 let commentToolOn = false
90 let sessionEnded = false
91 let lastPointer = { x: 0, y: 0 }
92 let sawPointer = false
93 const frozenStyles = []
94 let inFlight = false
95 let reloadPending = false
96 let leavingPage = false
97 let draft = null
98 let source = null
99 // The helper's view of each annotation's lifecycle, keyed by id. Pin status
100 // follows it; a reload or a screen change never changes a pin on its own.
101 let annotationStates = {}
102 const pins = []
103 // Pins whose target or variant is not on view right now. Derived on each
104 // reattach, so it stays out of the persisted pin state.
105 const offViewPins = new Set()
107 function prototypeRoot() {
108 return document.getElementById("ce-prototype-root") || document.body
109 }
111 // A revised screen is shown by navigating to the stamped served path, so a
112 // History API rewrite is not what the helper looks up. Query and fragment
113 // on that path stay. The browser owns every reconciliation; pins, tool
114 // state, and open draft are carried across explorer navigation as well as
115 // helper-driven reloads.
116 function persistState() {
117 const state = {
118 commentToolOn,
119 sessionEnded,
120 pins,
121 annotationStates,
122 draft: draft
123 ? { ...draft, page: servedPage, text: commentField.value, left: composer.style.left, top: composer.style.top }
124 : null,
125 }
126 try {
127 sessionStorage.setItem(STATE_KEY, JSON.stringify(state))
128 } catch {
129 // Without storage a navigation still shows the next screen; only the pins are lost.
130 }
131 }
133 function persistAndReload() {
134 persistState()
135 window.location.replace(`${servedPage}${window.location.search}${window.location.hash}`)
136 }
138 // A screen change arriving while a comment is being sent waits for that
139 // request to settle, so the pin gets its id (or its retry) before the reload.
140 function requestReload() {
141 if (inFlight) {
142 reloadPending = true
143 return
144 }
145 if (source) source.close()
146 persistAndReload()
147 }
149 function restorePersistedState() {
150 let saved = null
151 try {
152 saved = JSON.parse(sessionStorage.getItem(STATE_KEY) || "null")
153 } catch {
154 return false
155 }
156 if (!saved || typeof saved !== "object") return false
157 if (saved.annotationStates && typeof saved.annotationStates === "object" && !Array.isArray(saved.annotationStates)) {
158 annotationStates = { ...saved.annotationStates, ...annotationStates }
159 }
160 if (Array.isArray(saved.pins)) {
161 for (const pin of saved.pins) {
162 if (!pin || typeof pin.selector !== "string" || typeof pin.comment !== "string") continue
163 if (pin.id && pins.some((existing) => existing.id === pin.id)) continue
164 pins.push(pin)
165 }
166 }
167 dropAppliedPins()
168 if (saved.sessionEnded) {
169 markEnded()
170 return true
171 }
172 if (saved.commentToolOn && !agentHasBatch()) setCommentTool(true)
173 if (
174 saved.draft &&
175 typeof saved.draft.selector === "string" &&
176 (!saved.draft.page || saved.draft.page === servedPage) &&
177 !agentHasBatch()
178 ) {
179 restoreDraft(saved.draft)
180 }
181 syncStopButton()
182 return true
183 }
185 function restoreDraft(saved) {
186 let node = null
187 try {
188 node = document.querySelector(saved.selector)
189 } catch {
190 node = null
191 }
192 draft = { selector: saved.selector, textSnippet: saved.textSnippet, variant: saved.variant, rect: saved.rect, point: saved.point, x: saved.x, y: saved.y }
193 composer.hidden = false
194 if (node && !pinOffView(node, draft)) {
195 const rect = node.getBoundingClientRect()
196 Object.assign(draft, positionFromNode(node, draft))
197 placeComposer(rect.left, rect.top + rect.height)
198 } else {
199 composer.style.left = saved.left || ""
200 composer.style.top = saved.top || ""
201 }
202 commentField.value = typeof saved.text === "string" ? saved.text : ""
203 error.hidden = true
204 commentField.focus()
205 syncSubmit()
206 }
208 function applyAnnotationStates(states) {
209 if (!states || typeof states !== "object") return
210 annotationStates = states
211 dropAppliedPins()
212 for (const pin of pins) {
213 const status = PIN_STATUS[states[pin.id]]
214 if (status) pin.status = status
215 }
216 reattachPins()
217 syncStopButton()
218 }
220 // An applied note is answered by the revised screen. Its pin would only
221 // point at whatever the old selector matches now.
222 function dropAppliedPins() {
223 for (let i = pins.length - 1; i >= 0; i--) {
224 if (annotationStates[pins[i].id] === "done") pins.splice(i, 1)
225 }
226 }
228 function unflushedCount() {
229 const ids = new Set()
230 for (const [id, state] of Object.entries(annotationStates)) {
231 if (state === "held") ids.add(id)
232 }
233 for (const pin of pins) {
234 const state = pin.id ? annotationStates[pin.id] : "held"
235 if (!state || state === "held") ids.add(pin.id || `local:${pins.indexOf(pin)}`)
236 }
237 return ids.size
238 }
240 function pinOnThisPage(pin) {
241 return !pin.page || pin.page === servedPage
242 }
244 // Variants that share one screen behind a tab control are told apart by the
245 // data-ce-variant marker the build convention asks for. Without the marker,
246 // only a variant hidden in place (not re-rendered) hides its pins. The whole
247 // chain of enclosing markers, outermost first, counts, so nested controls
248 // that reuse inner names still tell their outer options apart.
249 function variantOf(node) {
250 const names = []
251 for (let el = node.closest?.("[data-ce-variant]"); el; el = el.parentElement?.closest("[data-ce-variant]")) {
252 names.unshift(el.getAttribute("data-ce-variant"))
253 }
254 return names.length ? names : null
255 }
257 function sameVariant(a, b) {
258 return JSON.stringify(a || null) === JSON.stringify(b || null)
259 }
261 function rendered(node) {
262 if (typeof node.checkVisibility === "function") {
263 return node.checkVisibility({ opacityProperty: true, visibilityProperty: true })
264 }
265 return node.getClientRects().length > 0
266 }
268 // A variant re-rendered out of the DOM leaves no node to check, so its own
269 // container being absent or hidden is what takes the pin off the view.
270 function pinOffView(node, pin) {
271 if (node) return !sameVariant(pin.variant, variantOf(node)) || !rendered(node)
272 if (!Array.isArray(pin.variant)) return false
273 const innermost = pin.variant[pin.variant.length - 1]
274 return ![...document.querySelectorAll(`[data-ce-variant="${CSS.escape(innermost)}"]`)].some(
275 (el) => sameVariant(pin.variant, variantOf(el)) && rendered(el),
276 )
277 }
279 function agentHasBatch() {
280 return Object.values(annotationStates).some((state) => state === "queued" || state === "working")
281 }
283 function syncStopButton() {
284 if (sessionEnded) return
285 if (agentHasBatch()) {
286 if (commentToolOn) setCommentTool(false)
287 toggle.hidden = true
288 stop.hidden = true
289 setStatus("Sent to agent")
290 status.title = "Return to chat. You can annotate again after this batch is applied."
291 return
292 }
293 toggle.hidden = false
294 stop.hidden = false
295 status.removeAttribute("title")
296 if (status.textContent === "Sent to agent") setStatus("")
297 const n = unflushedCount()
298 if (n === 0) {
299 stop.textContent = "End session"
300 stop.removeAttribute("aria-label")
301 stop.title = "Close the preview and return to chat"
302 return
303 }
304 const badge = document.createElement("span")
305 badge.className = "ce-annotate-count"
306 badge.textContent = String(n)
307 stop.replaceChildren(badge, document.createTextNode("Send to agent"))
308 const notes = n === 1 ? "1 note" : `${n} notes`
309 stop.setAttribute("aria-label", `Send ${notes} to the agent`)
310 stop.title = `Send ${notes} to the agent. The session stays open for another batch.`
311 }
313 function cssPath(el) {
314 if (!(el instanceof Element)) return ""
315 if (el.id) return `#${CSS.escape(el.id)}`
316 if (el === document.body) return "body"
317 const parts = []
318 let node = el
319 while (node && node.nodeType === 1 && node !== document.documentElement) {
320 if (node.id === "ce-prototype-root" || node === document.body) break
321 if (node.id) {
322 parts.unshift(`#${CSS.escape(node.id)}`)
323 break
324 }
325 const parent = node.parentElement
326 if (!parent) break
327 const tag = node.tagName.toLowerCase()
328 const same = [...parent.children].filter((child) => child.tagName === node.tagName)
329 const index = same.indexOf(node) + 1
330 parts.unshift(same.length > 1 ? `${tag}:nth-of-type(${index})` : tag)
331 node = parent
332 }
333 return parts.join(" > ")
334 }
336 function tokenUrl(path, extra) {
337 const url = new URL(path, window.location.origin)
338 if (token) url.searchParams.set("token", token)
339 if (extra) {
340 for (const [key, value] of Object.entries(extra)) {
341 if (value) url.searchParams.set(key, value)
342 }
343 }
344 return url.toString()
345 }
347 function setStatus(text) {
348 status.hidden = !text
349 status.textContent = text || ""
350 }
352 function syncSubmit() {
353 submit.disabled = sessionEnded || inFlight || commentField.value.trim() === ""
354 cancel.disabled = inFlight
355 }
357 function closeComposer(keep) {
358 composer.hidden = true
359 if (keep) return
360 draft = null
361 error.hidden = true
362 error.textContent = ""
363 commentField.value = ""
364 syncSubmit()
365 }
367 // Pins stay click-through so a note on a control never blocks that control;
368 // the comment shows by pointer position instead of a hover target.
369 const PIN_SIZE = 22
370 function showPinTipAt(x, y) {
371 const pin = pins.findLast(
372 (p) => pinOnThisPage(p) && !offViewPins.has(p) && x >= p.x && x <= p.x + PIN_SIZE && y >= p.y && y <= p.y + PIN_SIZE,
373 )
374 pinTip.hidden = !pin
375 if (!pin) return
376 pinTip.textContent = pin.comment
377 pinTip.style.left = `${Math.max(0, Math.min(pin.x + PIN_SIZE + 4, window.innerWidth - 260))}px`
378 pinTip.style.top = `${pin.y}px`
379 }
381 function renderPins() {
382 pinTip.hidden = true
383 layer.replaceChildren()
384 for (const pin of pins) {
385 if (!pinOnThisPage(pin) || offViewPins.has(pin)) continue
386 const marker = document.createElement("button")
387 marker.type = "button"
388 marker.className = `ce-annotate-pin is-${pin.status}`
389 marker.textContent = pin.status === "target-gone" ? "!" : pins.indexOf(pin) + 1
390 marker.title = pin.comment
391 marker.style.left = `${pin.x}px`
392 marker.style.top = `${pin.y}px`
393 layer.appendChild(marker)
394 }
395 }
397 // A pin stays where it was dropped inside its target, so a note on a large
398 // element does not jump to that element's corner.
399 function positionFromNode(node, pin) {
400 const rect = node.getBoundingClientRect()
401 const was = pin && pin.rect
402 if (was && pin.point && was.width > 0 && was.height > 0) {
403 const fx = Math.max(0, Math.min(1, (pin.point.x - was.x) / was.width))
404 const fy = Math.max(0, Math.min(1, (pin.point.y - was.y) / was.height))
405 return { x: rect.left + fx * rect.width, y: rect.top + fy * rect.height }
406 }
407 return { x: rect.left + Math.min(12, rect.width / 2), y: rect.top + 4 }
408 }
410 function reattachPins() {
411 const root = prototypeRoot()
412 offViewPins.clear()
413 for (const pin of pins) {
414 if (!pinOnThisPage(pin)) continue
415 let node = null
416 try {
417 node = root.querySelector(pin.selector) || document.querySelector(pin.selector)
418 } catch {
419 node = null
420 }
421 const queued = pin.status === "pending" || pin.status === "working"
422 if (pinOffView(node, pin)) {
423 offViewPins.add(pin)
424 continue
425 }
426 if (node) {
427 if (!queued) pin.status = "attached"
428 Object.assign(pin, positionFromNode(node, pin))
429 } else if (!queued) {
430 pin.status = "target-gone"
431 }
432 }
433 renderPins()
434 }
436 function markEnded() {
437 sessionEnded = true
438 setCommentTool(false)
439 toggle.hidden = true
440 stop.hidden = true
441 setStatus("Session ended")
442 closeComposer()
443 pins.length = 0
444 renderPins()
445 }
447 function openComposer(target, event) {
448 const selector = cssPath(target)
449 if (!selector) return
450 const box = target.getBoundingClientRect()
451 draft = {
452 x: event.clientX,
453 y: event.clientY,
454 selector,
455 textSnippet: (target.textContent || "").trim().slice(0, 240),
456 variant: variantOf(target),
457 rect: { x: box.left, y: box.top, width: box.width, height: box.height },
458 // On a canvas or empty space the target says little; the point is what
459 // the explorer indicated.
460 point: {
461 x: event.clientX,
462 y: event.clientY,
463 viewportWidth: window.innerWidth,
464 viewportHeight: window.innerHeight,
465 },
466 }
467 composer.hidden = false
468 placeComposer(event.clientX, event.clientY)
469 error.hidden = true
470 commentField.focus()
471 syncSubmit()
472 }
474 function placeComposer(x, y) {
475 const width = composer.offsetWidth || Math.min(260, window.innerWidth)
476 const height = composer.offsetHeight || Math.min(160, window.innerHeight)
477 composer.style.left = `${Math.max(0, Math.min(x + 12, window.innerWidth - width))}px`
478 composer.style.top = `${Math.max(0, Math.min(y + 12, window.innerHeight - height))}px`
479 }
481 function setCommentTool(on) {
482 commentToolOn = on
483 toggle.setAttribute("aria-pressed", String(on))
484 toggle.classList.toggle("is-on", on)
485 toggleLabel.textContent = on ? "Annotating" : "Annotate"
486 catcher.hidden = !on
487 if (!on) {
488 unfreezeHover()
489 closeComposer(inFlight)
490 }
491 }
493 function pageElementFromPoint(x, y) {
494 let node = null
495 for (const el of document.elementsFromPoint(x, y)) {
496 if (!(el instanceof Element)) continue
497 if (el === host || host.contains(el)) continue
498 if (el === document.documentElement) continue
499 node = el
500 break
501 }
502 return node || prototypeRoot()
503 }
505 // Hit testing skips pointer-events:none, which is how labels and headlines
506 // laid over a canvas are usually styled. What was indicated is the topmost
507 // painted element at the point with those counted, so every visible one
508 // smaller than the hit element joins a single hit test. One at a time would
509 // let a label win while another unhittable layer is painted over it.
510 function unhittableElementAt(x, y, hit) {
511 const hitBox = hit.getBoundingClientRect()
512 const hitArea = hitBox.width * hitBox.height
513 const candidates = []
514 for (const el of document.body.querySelectorAll("*")) {
515 if (el === host || host.contains(el)) continue
516 const box = el.getBoundingClientRect()
517 const area = box.width * box.height
518 if (!area || area >= hitArea) continue
519 if (x < box.left || x > box.right || y < box.top || y > box.bottom) continue
520 if (getComputedStyle(el).pointerEvents !== "none") continue
521 const visible = el.checkVisibility
522 ? el.checkVisibility({ opacityProperty: true, visibilityProperty: true })
523 : getComputedStyle(el).visibility === "visible"
524 if (visible && paintsAt(el, x, y)) candidates.push(el)
525 }
526 if (candidates.length === 0) return null
527 const saved = candidates.map((el) => [
528 el,
529 el.style.getPropertyValue("pointer-events"),
530 el.style.getPropertyPriority("pointer-events"),
531 ])
532 for (const el of candidates) el.style.setProperty("pointer-events", "auto", "important")
533 try {
534 // The top element can be a part of a candidate, such as a shape inside
535 // an svg, which became hittable by inheritance.
536 let top = pageElementFromPoint(x, y)
537 while (top && !candidates.includes(top)) top = top.parentElement
538 return top || null
539 } finally {
540 for (const [el, value, priority] of saved) {
541 if (value) el.style.setProperty("pointer-events", value, priority)
542 else el.style.removeProperty("pointer-events")
543 }
544 }
545 }
547 // A box that contains the point can paint nothing there: a positioned
548 // wrapper around labels has a box and no pixels of its own. Only what the
549 // element itself draws counts, so a wrapper's children are judged as themselves.
550 const REPLACED = new Set(["IMG", "SVG", "VIDEO", "CANVAS", "PICTURE", "IFRAME", "OBJECT", "EMBED", "INPUT", "SELECT", "TEXTAREA", "BUTTON"])
551 function paintsAt(el, x, y) {
552 if (REPLACED.has(el.tagName.toUpperCase())) return true
553 const style = getComputedStyle(el)
554 // Alpha is the fourth component; `rgb(255, 204, 0)` also ends in ", 0)".
555 const transparent = (color) => color === "transparent" || /^rgba\((?:[^,]+,){3}\s*0(?:\.0+)?\)$/.test(color)
556 if (!transparent(style.backgroundColor) || style.backgroundImage !== "none") return true
557 // A border paints only its own band, not the interior it surrounds.
558 const box = el.getBoundingClientRect()
559 const depth = { Top: y - box.top, Right: box.right - x, Bottom: box.bottom - y, Left: x - box.left }
560 for (const side of ["Top", "Right", "Bottom", "Left"]) {
561 const width = parseFloat(style[`border${side}Width`])
562 if (!(width > 0) || style[`border${side}Style`] === "none" || transparent(style[`border${side}Color`])) continue
563 if (depth[side] >= 0 && depth[side] <= width) return true
564 }
565 const range = document.createRange()
566 for (const node of el.childNodes) {
567 if (node.nodeType !== 3 || !node.nodeValue.trim()) continue
568 range.selectNodeContents(node)
569 for (const box of range.getClientRects()) {
570 if (x >= box.left && x <= box.right && y >= box.top && y <= box.bottom) return true
571 }
572 }
573 return false
574 }
576 function targetFromCatcher(event) {
577 catcher.style.pointerEvents = "none"
578 try {
579 const hit = pageElementFromPoint(event.clientX, event.clientY)
580 return unhittableElementAt(event.clientX, event.clientY, hit) || hit
581 } finally {
582 catcher.style.pointerEvents = ""
583 }
584 }
586 function unfreezeHover() {
587 for (const item of frozenStyles) {
588 for (const [prop, value, priority] of item.overrides) {
589 if (value) item.el.style.setProperty(prop, value, priority)
590 else item.el.style.removeProperty(prop)
591 }
592 }
593 frozenStyles.length = 0
594 }
596 const freezeExact = new Set([
597 "display", "visibility", "opacity", "overflow", "overflow-x", "overflow-y",
598 "color", "cursor", "z-index", "font-weight", "font-style",
599 "box-shadow", "text-shadow", "filter", "backdrop-filter", "mix-blend-mode",
600 "translate", "rotate", "scale", "clip-path",
601 ])
602 const freezePrefix = ["background-", "border-", "outline-", "text-decoration", "transform", "mask-", "fill", "stroke"]
604 function shouldFreezeProp(prop) {
605 if (prop.includes("webkit-text-fill") || prop.includes("webkit-text-stroke")) return false
606 if (prop === "border-block-size" || prop === "border-inline-size") return false
607 if (freezeExact.has(prop)) return true
608 return freezePrefix.some((prefix) => prop === prefix || prop.startsWith(prefix))
609 }
611 function freezeSubtree(root) {
612 const nodes = [root, ...root.querySelectorAll("*")]
613 for (const el of nodes) {
614 const overrides = []
615 const cs = getComputedStyle(el)
616 for (let i = 0; i < cs.length; i++) {
617 const prop = cs[i]
618 if (!shouldFreezeProp(prop)) continue
619 overrides.push([prop, el.style.getPropertyValue(prop), el.style.getPropertyPriority(prop)])
620 el.style.setProperty(prop, cs.getPropertyValue(prop))
621 }
622 frozenStyles.push({ el, overrides })
623 }
624 }
626 function freezeRootFrom(hit) {
627 const limit = prototypeRoot()
628 const areaCap = window.innerWidth * window.innerHeight * 0.5
629 let root = hit
630 while (root.parentElement) {
631 const parent = root.parentElement
632 if (parent === limit || parent === document.body || parent === document.documentElement) break
633 const box = parent.getBoundingClientRect()
634 if (box.width * box.height > areaCap) break
635 root = parent
636 }
637 freezeSubtree(root)
638 }
640 function freezeHoverThenAnnotate() {
641 catcher.style.pointerEvents = "none"
642 try {
643 if (sawPointer) {
644 unfreezeHover()
645 freezeRootFrom(pageElementFromPoint(lastPointer.x, lastPointer.y))
646 }
647 } finally {
648 catcher.style.pointerEvents = ""
649 }
650 setCommentTool(true)
651 }
653 function isTypingTarget(el) {
654 if (!(el instanceof Element)) return false
655 const tag = el.tagName
656 if (tag === "TEXTAREA" || tag === "SELECT") return true
657 if (tag === "INPUT") {
658 const type = (el.getAttribute("type") || "text").toLowerCase()
659 return !["button", "submit", "reset", "checkbox", "radio", "file", "color", "range", "hidden"].includes(type)
660 }
661 return el.isContentEditable
662 }
664 toggle.addEventListener("click", () => {
665 if (sessionEnded || agentHasBatch()) return
666 setCommentTool(!commentToolOn)
667 })
669 commentField.addEventListener("input", syncSubmit)
670 cancel.addEventListener("click", () => closeComposer())
672 composer.addEventListener("submit", async (event) => {
673 event.preventDefault()
674 if (!draft || inFlight || sessionEnded) return
675 const comment = commentField.value.trim()
676 if (!comment) return
677 // The composer can be closed (tool toggled off) while the request is in
678 // flight; the submission is complete in itself from here on.
679 // The path names the screen this pin is on; the helper resolves it to the
680 // file the agent edits. Path only: no query, so no token.
681 const payload = {
682 page: servedPage,
683 comment,
684 selector: draft.selector,
685 textSnippet: draft.textSnippet,
686 variant: draft.variant,
687 rect: draft.rect,
688 point: draft.point,
689 }
690 const submission = { ...payload, x: draft.x, y: draft.y }
691 inFlight = true
692 syncSubmit()
693 error.hidden = true
694 try {
695 const response = await fetch(tokenUrl("/annotation"), {
696 method: "POST",
697 headers: { "Content-Type": "application/json" },
698 body: JSON.stringify(payload),
699 })
700 if (!response.ok) throw new Error("retry")
701 const { id } = await response.json()
702 pins.push({ ...submission, id, status: PIN_STATUS[annotationStates[id]] || "pending" })
703 renderPins()
704 syncStopButton()
705 persistState()
706 closeComposer()
707 } catch {
708 if (!sessionEnded) {
709 composer.hidden = false
710 error.hidden = false
711 error.textContent = "Could not send — retry"
712 }
713 } finally {
714 inFlight = false
715 syncSubmit()
716 }
717 if (reloadPending && !sessionEnded) requestReload()
718 })
720 stop.addEventListener("click", async () => {
721 if (sessionEnded) return
722 stop.disabled = true
723 while (inFlight && !sessionEnded) {
724 await new Promise((resolve) => setTimeout(resolve, 20))
725 }
726 if (sessionEnded) return
727 const sending = unflushedCount() > 0
728 try {
729 const response = await fetch(tokenUrl(sending ? "/session/flush" : "/session/end"), { method: "POST" })
730 if (!response.ok) throw new Error("retry")
731 } catch {
732 stop.disabled = false
733 setStatus(sending ? "Could not send to agent — retry" : "Could not end session — retry")
734 return
735 }
736 if (sending) {
737 for (const id of Object.keys(annotationStates)) {
738 if (annotationStates[id] === "held") annotationStates[id] = "queued"
739 }
740 for (const pin of pins) {
741 if (pin.id && (!annotationStates[pin.id] || annotationStates[pin.id] === "held")) {
742 annotationStates[pin.id] = "queued"
743 }
744 }
745 persistState()
746 stop.disabled = false
747 syncStopButton()
748 return
749 }
750 markEnded()
751 })
753 window.addEventListener("pagehide", persistState)
754 // Leaving the page aborts the event stream before pagehide; that abort is
755 // not the helper ending the session, and pagehide would persist it as one.
756 window.addEventListener("beforeunload", () => {
757 leavingPage = true
758 })
759 window.addEventListener("pageshow", (event) => {
760 leavingPage = false
761 if (event.persisted) restorePersistedState()
762 })
763 window.addEventListener("scroll", reattachPins, { capture: true, passive: true })
764 window.addEventListener("resize", reattachPins)
765 const layoutRoot = prototypeRoot()
766 if (typeof ResizeObserver === "function") {
767 new ResizeObserver(reattachPins).observe(layoutRoot)
768 }
769 if (typeof MutationObserver === "function") {
770 new MutationObserver(reattachPins).observe(layoutRoot, {
771 subtree: true,
772 childList: true,
773 attributes: true,
774 attributeFilter: ["style", "class", "hidden", "data-ce-variant"],
775 })
776 }
778 catcher.addEventListener("click", (event) => {
779 if (!commentToolOn || sessionEnded || agentHasBatch()) return
780 event.preventDefault()
781 event.stopPropagation()
782 const target = targetFromCatcher(event)
783 if (!(target instanceof Element)) return
784 openComposer(target, event)
785 })
787 document.addEventListener("pointermove", (event) => {
788 sawPointer = true
789 lastPointer.x = event.clientX
790 lastPointer.y = event.clientY
791 showPinTipAt(event.clientX, event.clientY)
792 }, { capture: true, passive: true })
794 document.addEventListener("keydown", (event) => {
795 if (sessionEnded || agentHasBatch()) return
796 if (event.key === "Escape") {
797 if (!commentToolOn && composer.hidden) return
798 event.preventDefault()
799 event.stopPropagation()
800 setCommentTool(false)
801 return
802 }
803 if (!event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return
804 if (event.key.toLowerCase() !== "a") return
805 const path = event.composedPath()
806 if (path.some((node) => isTypingTarget(node))) return
807 if (isTypingTarget(shadow.activeElement) || isTypingTarget(document.activeElement)) return
808 event.preventDefault()
809 event.stopPropagation()
810 if (commentToolOn) {
811 setCommentTool(false)
812 return
813 }
814 freezeHoverThenAnnotate()
815 }, true)
817 if (restorePersistedState()) reattachPins()
818 syncStopButton()
820 if ("EventSource" in window) {
821 source = new EventSource(tokenUrl("/events", { document: servedDocument }))
822 source.addEventListener("screen-changed", requestReload)
823 source.addEventListener("annotations", (event) => {
824 let states
825 try {
826 states = JSON.parse(event.data)
827 } catch {
828 return
829 }
830 applyAnnotationStates(states)
831 })
832 source.addEventListener("session-ended", markEnded)
833 source.addEventListener("error", () => {
834 if (source.readyState === EventSource.CLOSED && !leavingPage) markEnded()
835 })
836 }
837})()