/** * The annotation overlay that runs *inside* the previewed page. * * This module produces a self-contained script string. It is injected through * `webview.executeJavaScript`, so it cannot import anything, cannot reference * our theme variables (the page has its own `:root`), and must not disturb the * document it lands in. Consequences that drive the implementation: * * - All chrome lives in a **closed** shadow root on a single host element, so * page CSS cannot restyle it and page scripts cannot walk into it. * - Every color and every label is passed in from the host, already resolved * and already translated. Nothing user-facing is hardcoded here. * * The chrome is two small pieces rather than one bar: the tools sit at the top * of the page, and the comment box follows whatever was just marked. A single * bar pinned to one edge covers the part of the page people most often want to * point at, and accumulates into something that feels like an application. * * Lifecycle: the script resolves with a payload (or null when cancelled) and * tears down its own chrome *before* resolving, waiting for a repaint, so a * screenshot taken by the host contains the page and none of our UI. */ export type BrowserAnnotationOverlayTheme = { readonly colorScheme: 'light' | 'dark'; readonly primary: string; /** Translucent primary, already resolved to a concrete color by the host. */ readonly primarySoft: string; /** Faint primary used for hover backgrounds. */ readonly primaryFaint: string; readonly primaryContrast: string; readonly surface: string; readonly surfaceElevated: string; /** Translucent elevated surface, matching the app's floating panels. */ readonly glassSurface: string; /** A ready `backdrop-filter` value; '' when the theme has no glass. */ readonly glassFilter: string; readonly border: string; readonly text: string; readonly mutedText: string; }; export type BrowserAnnotationOverlayLabels = { readonly select: string; readonly marquee: string; readonly draw: string; readonly commentPlaceholder: string; readonly submit: string; }; const ANNOTATION_ACTIVE_FLAG = '__openchamberBrowserAnnotationActive'; /** Cancels an overlay left behind by an earlier session. Safe when none is active. */ export const ANNOTATION_TEARDOWN_SCRIPT = `(() => { try { var host = document.querySelector('[data-openchamber-annotation]'); if (host && host.parentNode) host.parentNode.removeChild(host); } catch (error) { /* page navigated away */ } try { var cursor = document.getElementById('openchamber-annotation-cursor'); if (cursor && cursor.parentNode) cursor.parentNode.removeChild(cursor); } catch (error) { /* page navigated away */ } try { delete window['${ANNOTATION_ACTIVE_FLAG}']; } catch (error) { /* non-configurable */ } })();`; export const buildAnnotationOverlayScript = ( theme: BrowserAnnotationOverlayTheme, labels: BrowserAnnotationOverlayLabels, ): string => { const config = JSON.stringify({ theme, labels }); return String.raw`new Promise((resolve) => { var CONFIG = ${config}; var THEME = CONFIG.theme; var LABELS = CONFIG.labels; var OVERLAY_ATTR = 'data-openchamber-annotation'; var Z_OVERLAY = 2147483646; var MAX_TEXT = 400; var MIN_RECT = 3; var EDITOR_GAP = 10; try { var stale = document.querySelector('[' + OVERLAY_ATTR + ']'); if (stale && stale.parentNode) stale.parentNode.removeChild(stale); } catch (error) { /* nothing to clean */ } window['${ANNOTATION_ACTIVE_FLAG}'] = true; var counter = 0; var nextId = function (prefix) { counter += 1; return prefix + '-' + counter; }; var selected = null; var regions = []; var strokes = []; var tool = 'select'; var settled = false; // ---------------------------------------------------------------- geometry var rectFrom = function (domRect) { return { x: domRect.left, y: domRect.top, width: domRect.width, height: domRect.height }; }; var normalizeRect = function (ax, ay, bx, by) { return { x: Math.min(ax, bx), y: Math.min(ay, by), width: Math.abs(bx - ax), height: Math.abs(by - ay) }; }; var usableRect = function (rect) { return rect.width >= MIN_RECT && rect.height >= MIN_RECT; }; // ------------------------------------------------------------- description var isOverlayNode = function (element) { var node = element; while (node) { if (node.nodeType === 1 && node.hasAttribute && node.hasAttribute(OVERLAY_ATTR)) return true; node = node.parentNode || (node.host || null); } return false; }; var elementFromPoint = function (x, y) { var found = document.elementFromPoint(x, y); if (!found || isOverlayNode(found)) return null; return found; }; var selectorPart = function (element) { var part = element.tagName.toLowerCase(); if (element.id) return part + '#' + element.id; var className = typeof element.className === 'string' ? element.className.trim() : ''; if (className) { var first = className.split(/\s+/).filter(Boolean).slice(0, 2).join('.'); if (first) part += '.' + first; } return part; }; var buildSelector = function (element) { if (element.id) return '#' + element.id; var parts = []; var node = element; var depth = 0; while (node && node.nodeType === 1 && depth < 5) { var part = selectorPart(node); var parent = node.parentElement; if (parent) { var siblings = Array.prototype.filter.call(parent.children, function (child) { return child.tagName === node.tagName; }); if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')'; } parts.unshift(part); if (node.id) break; node = parent; depth += 1; } return parts.join(' > '); }; var INTERESTING_ATTRS = ['id', 'class', 'name', 'type', 'href', 'src', 'alt', 'title', 'role', 'placeholder', 'aria-label', 'data-testid']; var STYLE_PROPS = [ 'display', 'position', 'color', 'backgroundColor', 'fontSize', 'fontWeight', 'fontFamily', 'lineHeight', 'padding', 'margin', 'border', 'borderRadius', 'width', 'height', 'opacity', 'zIndex', 'flexDirection', 'justifyContent', 'alignItems', 'gap', 'textAlign' ]; var describe = function (element) { var computed = window.getComputedStyle(element); var attributes = {}; for (var i = 0; i < INTERESTING_ATTRS.length; i += 1) { var name = INTERESTING_ATTRS[i]; var value = element.getAttribute(name); if (value) attributes[name] = String(value).slice(0, 200); } var computedStyle = {}; for (var j = 0; j < STYLE_PROPS.length; j += 1) { var prop = STYLE_PROPS[j]; computedStyle[prop] = String(computed[prop] == null ? '' : computed[prop]); } var ancestry = []; var node = element.parentElement; var depth = 0; while (node && node.nodeType === 1 && depth < 4) { var entry = { tag: node.tagName.toLowerCase(), selectorPart: selectorPart(node) }; if (node.id) entry.id = node.id; var cls = typeof node.className === 'string' ? node.className.trim() : ''; if (cls) entry.className = cls.slice(0, 200); ancestry.unshift(entry); node = node.parentElement; depth += 1; } var box = element.getBoundingClientRect(); var text = (element.textContent == null ? '' : String(element.textContent)).replace(/\s+/g, ' ').trim(); return { tag: element.tagName.toLowerCase(), text: text.slice(0, MAX_TEXT), selector: buildSelector(element), path: ancestry.map(function (e) { return e.selectorPart; }).concat([selectorPart(element)]).join(' > '), bounds: rectFrom(box), center: { x: box.left + box.width / 2, y: box.top + box.height / 2 }, attributes: attributes, computedStyle: computedStyle, ancestry: ancestry }; }; // ------------------------------------------------------------------ chrome var host = document.createElement('div'); host.setAttribute(OVERLAY_ATTR, ''); host.style.cssText = 'position:fixed;inset:0;z-index:' + Z_OVERLAY + ';pointer-events:none'; var shadow = host.attachShadow({ mode: 'closed' }); var style = document.createElement('style'); style.textContent = [ ':host{all:initial}', '*{box-sizing:border-box;font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}', '.layer{position:fixed;inset:0;pointer-events:none}', '.box{position:fixed;left:0;top:0;display:none;pointer-events:none;border:1.5px solid ' + THEME.primary + ';background:' + THEME.primarySoft + ';border-radius:2px}', '.label{position:fixed;left:0;top:0;display:none;pointer-events:none;padding:1px 6px;border-radius:4px;background:' + THEME.primary + ';color:' + THEME.primaryContrast + ';font-size:11px;line-height:17px;white-space:nowrap;font-weight:600}', '.tools{position:fixed;top:14px;left:50%;transform:translateX(-50%);display:flex;gap:2px;padding:4px;border-radius:999px;border:1px solid ' + THEME.border + ';background:' + THEME.glassSurface + ';-webkit-backdrop-filter:' + THEME.glassFilter + ';backdrop-filter:' + THEME.glassFilter + ';box-shadow:0 6px 20px rgba(0,0,0,.24);pointer-events:auto}', '.tools button{appearance:none;border:none;background:transparent;color:' + THEME.mutedText + ';border-radius:999px;padding:5px 14px;font-size:12px;line-height:18px;font-weight:500;cursor:pointer;white-space:nowrap}', '.tools button:hover{background:' + THEME.primaryFaint + '}', '.tools button[aria-pressed="true"]{background:' + THEME.primarySoft + ';color:' + THEME.primary + ';font-weight:600}', '.editor{position:fixed;left:0;top:0;display:none;align-items:center;gap:8px;width:min(420px,calc(100vw - 24px));padding:6px;padding-left:16px;border-radius:22px;border:1px solid ' + THEME.border + ';background:' + THEME.glassSurface + ';-webkit-backdrop-filter:' + THEME.glassFilter + ';backdrop-filter:' + THEME.glassFilter + ';box-shadow:0 8px 28px rgba(0,0,0,.3);pointer-events:auto}', '.editor textarea{flex:1;min-width:0;resize:none;border:none;background:transparent;color:' + THEME.text + ';font-size:13px;line-height:20px;outline:none;padding:6px 0;min-height:32px;max-height:104px;display:block}', '.editor textarea::placeholder{color:' + THEME.mutedText + '}', '.editor button{appearance:none;border:none;background:' + THEME.primary + ';color:' + THEME.primaryContrast + ';border-radius:999px;width:32px;height:32px;padding:0;display:flex;align-items:center;justify-content:center;flex-shrink:0;cursor:pointer}', '.editor button svg{width:16px;height:16px;display:block}', '.editor button[disabled]{opacity:.5;cursor:default}' ].join(''); shadow.appendChild(style); var cursorStyle = document.createElement('style'); cursorStyle.id = 'openchamber-annotation-cursor'; document.head.appendChild(cursorStyle); var setCursor = function (value) { cursorStyle.textContent = value ? '*{cursor:' + value + ' !important}' : ''; }; var svgNS = 'http://www.w3.org/2000/svg'; var svg = document.createElementNS(svgNS, 'svg'); svg.setAttribute('class', 'layer'); svg.style.overflow = 'visible'; shadow.appendChild(svg); var hoverBox = document.createElement('div'); hoverBox.className = 'box'; var hoverLabel = document.createElement('div'); hoverLabel.className = 'label'; var marqueeBox = document.createElement('div'); marqueeBox.className = 'box'; shadow.append(hoverBox, hoverLabel, marqueeBox); var positionBox = function (node, rect) { node.style.display = 'block'; node.style.transform = 'translate(' + rect.x + 'px,' + rect.y + 'px)'; node.style.width = rect.width + 'px'; node.style.height = rect.height + 'px'; }; var tools = document.createElement('div'); tools.className = 'tools'; shadow.appendChild(tools); var toolButtons = {}; [['select', LABELS.select], ['marquee', LABELS.marquee], ['draw', LABELS.draw]].forEach(function (entry) { var button = document.createElement('button'); button.type = 'button'; button.textContent = entry[1]; button.addEventListener('click', function () { setTool(entry[0]); }); toolButtons[entry[0]] = button; tools.appendChild(button); }); var editor = document.createElement('div'); editor.className = 'editor'; shadow.appendChild(editor); var comment = document.createElement('textarea'); comment.rows = 1; comment.placeholder = LABELS.commentPlaceholder; var submit = document.createElement('button'); submit.type = 'button'; // Icon-only attach button (Remix attachment-2), matching the chat comment // input; the localized label stays available to assistive tech. submit.setAttribute('aria-label', LABELS.submit); submit.title = LABELS.submit; submit.innerHTML = ''; editor.append(comment, submit); /** * Grows the box with its content. * * A hidden element reports scrollHeight 0, so measuring one collapses the * field to nothing — which is what left a sliver of a click target and an * invisible caret. Measuring only while visible, and never below the CSS * min-height, keeps it usable. */ var resizeComment = function () { if (editor.style.display === 'none') return; comment.style.height = 'auto'; comment.style.height = Math.max(32, Math.min(comment.scrollHeight, 104)) + 'px'; }; comment.addEventListener('input', resizeComment); // -------------------------------------------------------------- selection var lastTargetRect = null; /** Places the comment box under whatever was just marked, kept on screen. */ var positionEditor = function () { if (!lastTargetRect) { editor.style.display = 'none'; return; } var wasHidden = editor.style.display === 'none'; editor.style.display = 'flex'; // Measured now that it is laid out, not while it was still hidden. if (wasHidden) resizeComment(); var box = editor.getBoundingClientRect(); var left = lastTargetRect.x + lastTargetRect.width / 2 - box.width / 2; var top = lastTargetRect.y + lastTargetRect.height + EDITOR_GAP; // Above the target when there is no room below, so the box is never left // half off the bottom of the page. if (top + box.height > window.innerHeight - 8) { top = Math.max(8, lastTargetRect.y - box.height - EDITOR_GAP); } left = Math.max(8, Math.min(left, window.innerWidth - box.width - 8)); editor.style.transform = 'translate(' + Math.round(left) + 'px,' + Math.round(top) + 'px)'; }; /** Focus after layout: focusing a zero-height field puts the caret nowhere. */ var focusComment = function () { requestAnimationFrame(function () { try { comment.focus({ preventScroll: true }); comment.setSelectionRange(comment.value.length, comment.value.length); } catch (error) { /* field went away */ } }); }; var hasTargets = function () { return Boolean(selected) || regions.length > 0 || strokes.length > 0; }; var syncChrome = function () { submit.disabled = !hasTargets(); if (!hasTargets()) { lastTargetRect = null; editor.style.display = 'none'; return; } positionEditor(); }; var syncSelectionVisuals = function () { if (!selected) return; var rect = rectFrom(selected.element.getBoundingClientRect()); if (!usableRect(rect)) { selected.outline.style.display = 'none'; selected.badge.style.display = 'none'; return; } positionBox(selected.outline, rect); selected.badge.style.display = 'block'; selected.badge.style.transform = 'translate(' + Math.max(2, rect.x) + 'px,' + Math.max(2, rect.y - 19) + 'px)'; lastTargetRect = rect; }; var dropSelection = function () { if (!selected) return; selected.outline.remove(); selected.badge.remove(); selected = null; }; var selectElement = function (element) { var wasSelected = selected && selected.element === element; dropSelection(); if (wasSelected) { // Clicking the chosen element again clears it, so a misclick costs one // click rather than starting over. lastTargetRect = null; syncChrome(); return; } var outline = document.createElement('div'); outline.className = 'box'; var badge = document.createElement('div'); badge.className = 'label'; badge.textContent = selectorPart(element); shadow.append(outline, badge); selected = { id: nextId('element'), element: element, outline: outline, badge: badge }; syncSelectionVisuals(); syncChrome(); focusComment(); }; var setTool = function (next) { tool = next; hoverBox.style.display = 'none'; hoverLabel.style.display = 'none'; marqueeBox.style.display = 'none'; setCursor(next === 'select' ? '' : 'crosshair'); for (var key in toolButtons) { if (Object.prototype.hasOwnProperty.call(toolButtons, key)) { toolButtons[key].setAttribute('aria-pressed', key === next ? 'true' : 'false'); } } }; // ------------------------------------------------------------- interaction var drag = null; var onPointerMove = function (event) { if (drag) { if (drag.kind === 'marquee') { drag.rect = normalizeRect(drag.startX, drag.startY, event.clientX, event.clientY); positionBox(marqueeBox, drag.rect); } else if (drag.kind === 'draw') { drag.points.push({ x: event.clientX, y: event.clientY }); drag.path.setAttribute('d', drag.points.map(function (point, index) { return (index === 0 ? 'M' : 'L') + point.x + ' ' + point.y; }).join(' ')); } return; } if (tool !== 'select') return; var element = elementFromPoint(event.clientX, event.clientY); if (!element) { hoverBox.style.display = 'none'; hoverLabel.style.display = 'none'; return; } var rect = rectFrom(element.getBoundingClientRect()); positionBox(hoverBox, rect); hoverLabel.textContent = selectorPart(element); hoverLabel.style.display = 'block'; hoverLabel.style.transform = 'translate(' + Math.max(2, rect.x) + 'px,' + Math.max(2, rect.y - 19) + 'px)'; }; var onPointerDown = function (event) { if (event.button !== 0) return; if (isOverlayNode(event.target)) return; event.preventDefault(); event.stopPropagation(); if (tool === 'select') { var element = elementFromPoint(event.clientX, event.clientY); if (element) selectElement(element); return; } if (tool === 'marquee') { drag = { kind: 'marquee', startX: event.clientX, startY: event.clientY, rect: null }; return; } var path = document.createElementNS(svgNS, 'path'); path.setAttribute('fill', 'none'); path.setAttribute('stroke', THEME.primary); path.setAttribute('stroke-width', '2.5'); path.setAttribute('stroke-linecap', 'round'); path.setAttribute('stroke-linejoin', 'round'); svg.appendChild(path); drag = { kind: 'draw', path: path, points: [{ x: event.clientX, y: event.clientY }] }; }; var onPointerUp = function () { if (!drag) return; var finished = drag; drag = null; if (finished.kind === 'marquee') { marqueeBox.style.display = 'none'; var rect = finished.rect; if (!rect || !usableRect(rect)) return; // A region marks an area, not the elements inside it: expanding it into a // selection made the result depend on the page's markup rather than on // what was drawn. regions.push({ id: nextId('region'), rect: rect }); var outline = document.createElementNS(svgNS, 'rect'); outline.setAttribute('x', String(rect.x)); outline.setAttribute('y', String(rect.y)); outline.setAttribute('width', String(rect.width)); outline.setAttribute('height', String(rect.height)); outline.style.fill = THEME.primarySoft; outline.setAttribute('stroke', THEME.primary); outline.setAttribute('stroke-width', '1.5'); outline.setAttribute('rx', '2'); svg.appendChild(outline); lastTargetRect = rect; syncChrome(); focusComment(); return; } var points = finished.points; if (points.length < 2) { if (finished.path.parentNode) finished.path.parentNode.removeChild(finished.path); return; } var minX = points[0].x, minY = points[0].y, maxX = points[0].x, maxY = points[0].y; for (var p = 1; p < points.length; p += 1) { minX = Math.min(minX, points[p].x); minY = Math.min(minY, points[p].y); maxX = Math.max(maxX, points[p].x); maxY = Math.max(maxY, points[p].y); } var bounds = { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; strokes.push({ id: nextId('stroke'), points: points, bounds: bounds }); lastTargetRect = bounds; syncChrome(); focusComment(); }; /** * Swallows the page's own mouse handling while annotating. * * Blocking pointerdown is not enough: a click still follows it, so marking a * button pressed that button and marking a link navigated away from the page * being annotated. These are the events that reach page code, so these are * the ones that have to stop. */ var blockPageInteraction = function (event) { if (isOverlayNode(event.target)) return; event.preventDefault(); event.stopPropagation(); }; var BLOCKED_EVENTS = ['click', 'auxclick', 'dblclick', 'mousedown', 'mouseup', 'contextmenu']; var onScrollOrResize = function () { syncSelectionVisuals(); positionEditor(); }; var onKeyDown = function (event) { if (event.key === 'Escape') { event.preventDefault(); event.stopImmediatePropagation(); finish(null); } }; var onCommentKeyDown = function (event) { // Do not let the annotated page treat typed letters as its own shortcuts. event.stopPropagation(); if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); attach(); } }; document.addEventListener('pointermove', onPointerMove, true); document.addEventListener('pointerdown', onPointerDown, true); document.addEventListener('pointerup', onPointerUp, true); BLOCKED_EVENTS.forEach(function (name) { window.addEventListener(name, blockPageInteraction, { capture: true, passive: false }); }); window.addEventListener('scroll', onScrollOrResize, true); window.addEventListener('resize', onScrollOrResize, true); window.addEventListener('keydown', onKeyDown, true); comment.addEventListener('keydown', onCommentKeyDown); // ------------------------------------------------------------------ finish var teardown = function () { document.removeEventListener('pointermove', onPointerMove, true); document.removeEventListener('pointerdown', onPointerDown, true); document.removeEventListener('pointerup', onPointerUp, true); BLOCKED_EVENTS.forEach(function (name) { window.removeEventListener(name, blockPageInteraction, true); }); window.removeEventListener('scroll', onScrollOrResize, true); window.removeEventListener('resize', onScrollOrResize, true); window.removeEventListener('keydown', onKeyDown, true); comment.removeEventListener('keydown', onCommentKeyDown); setCursor(''); if (cursorStyle.parentNode) cursorStyle.parentNode.removeChild(cursorStyle); if (host.parentNode) host.parentNode.removeChild(host); try { delete window['${ANNOTATION_ACTIVE_FLAG}']; } catch (error) { /* non-configurable */ } }; var finish = function (payload) { if (settled) return; settled = true; teardown(); // Removing the chrome is not enough: the host screenshots this page right // after we resolve, and the compositor can still hold a frame containing // our outlines. Yield two frames so the page has actually repainted. requestAnimationFrame(function () { requestAnimationFrame(function () { resolve(payload); }); }); }; var attach = function () { if (!hasTargets()) return; finish({ id: 'annotation-' + Date.now(), pageUrl: String(location.href), pageTitle: String(document.title || ''), viewport: { width: window.innerWidth, height: window.innerHeight }, devicePixelRatio: window.devicePixelRatio || 1, comment: comment.value, // Zero or one element, never more. Kept as a list so elements, regions // and strokes stay one uniform shape for everything downstream. elements: selected ? [{ id: selected.id, element: describe(selected.element) }] : [], regions: regions.map(function (entry) { return { id: entry.id, rect: entry.rect }; }), strokes: strokes.map(function (entry) { return { id: entry.id, points: entry.points, bounds: entry.bounds }; }) }); }; submit.addEventListener('click', attach); (document.body || document.documentElement).appendChild(host); setTool('select'); syncChrome(); });`; };