diff --git a/apps/cli/ai/inspector/inspector-inject.ts b/apps/cli/ai/inspector/inspector-inject.ts index 2b724c482b..27944bea18 100644 --- a/apps/cli/ai/inspector/inspector-inject.ts +++ b/apps/cli/ai/inspector/inspector-inject.ts @@ -5,7 +5,7 @@ * The inspector is a self-contained vanilla-DOM widget defined in * `./page-script.ts` — no React, no esm.sh, no external dependencies, no * page-CSS conflicts. The user clicks elements, types comments, and clicks - * "Done" to send everything back to the CLI via `window.__studioAnnotateDone`. + * "Send to agent" to return everything via `window.__studioAnnotateDone`. */ import { launchChromiumWithInstall } from 'cli/ai/browser-utils'; @@ -72,7 +72,7 @@ export interface AnnotationDoneResult { } /** - * Block until the user clicks "Done" in the inspector toolbar, then return + * Block until the user clicks "Send to agent" in the inspector toolbar, then return * the annotations. Reads from `window.__studioAnnotateDone` which the page * script populates from its in-memory state and localStorage. */ @@ -103,7 +103,7 @@ export async function waitForAnnotationsDone( // Auto-close the browser once we've captured the annotations. This // makes the lifecycle unambiguous from the user's point of view — - // clicking "Done" closes the window — and removes the failure mode + // clicking "Send to agent" closes the window — and removes the failure mode // where a user re-annotates after the agent has already moved on, with // the new payload sitting on `window.__studioAnnotateDone` but nobody // polling for it. To start another round, the user just re-runs the @@ -132,7 +132,7 @@ export async function openAnnotationBrowser( siteUrl: string ): Promise< string try { await inspectorPage.bringToFront(); await injectInspector( inspectorPage ); - return 'Inspector reattached to the open browser. Click "Annotate" to pick an element, then "Done" when finished.'; + return 'Inspector reattached to the open browser. Click "Annotate" to pick an element, then "Send to agent" when finished.'; } catch { await shutdownBrowser(); } @@ -194,5 +194,5 @@ export async function openAnnotationBrowser( siteUrl: string ): Promise< string installProcessExitHook(); - return `Annotation browser opened at ${ siteUrl }. Click "Annotate" in the bottom-right toolbar, click an element, type your feedback, then click "Done" when you're finished.`; + return `Annotation browser opened at ${ siteUrl }. Click "Annotate" in the bottom-right toolbar, click an element, type your feedback, then click "Send to agent" when you're finished.`; } diff --git a/apps/cli/ai/inspector/page-script.ts b/apps/cli/ai/inspector/page-script.ts index e6c709f1ee..04e696bd32 100644 --- a/apps/cli/ai/inspector/page-script.ts +++ b/apps/cli/ai/inspector/page-script.ts @@ -1,612 +1,3 @@ -/** - * Self-contained annotation inspector injected into the page. - * - * Lets the user click elements to attach comments, then ship the whole batch - * back to the CLI by clicking "Done". Vanilla DOM, no React, no esm.sh, no - * external imports — written this way so injection is reliable on any - * WordPress page regardless of CSP, network access, or theme CSS. - * - * Storage: a single localStorage key `studio-inspector-annotations-v1`, - * scoped per pathname inside the JSON, so refreshing the page or navigating - * between site URLs doesn't lose work. - * - * Hand-off to the agent: clicking "Done" sets `window.__studioAnnotateDone`, - * which `waitForAnnotationsDone()` in inspector-inject.ts polls for. - */ +import { createCliInspectorPageScript } from '@studio/common/ai/inspector-page-script'; -export const INSPECTOR_PAGE_SCRIPT = - String.raw` -( () => { - if ( window.__studioInspectorMounted ) { - return; - } - window.__studioInspectorMounted = true; - - const STORAGE_KEY = 'studio-inspector-annotations-v1'; - const HOST_ID = '__studio-inspector-host'; - - /* -------------------------------------------------------------------- - * Storage - * ------------------------------------------------------------------ */ - function loadAnnotations() { - try { - const raw = localStorage.getItem( STORAGE_KEY ); - const parsed = raw ? JSON.parse( raw ) : []; - return Array.isArray( parsed ) ? parsed : []; - } catch { - return []; - } - } - function saveAnnotations( list ) { - try { - localStorage.setItem( STORAGE_KEY, JSON.stringify( list ) ); - } catch {} - } - - /* -------------------------------------------------------------------- - * Element identification — build a CSS selector that's specific enough - * for the agent to find the element again via wp_cli/theme files. - * ------------------------------------------------------------------ */ - function buildSelector( el ) { - if ( ! el || el.nodeType !== 1 ) return ''; - if ( el.id ) return '#' + CSS.escape( el.id ); - const parts = []; - let node = el; - while ( node && node.nodeType === 1 && node !== document.documentElement ) { - let part = node.tagName.toLowerCase(); - if ( node.classList && node.classList.length ) { - const classes = Array.from( node.classList ) - .filter( ( c ) => ! c.startsWith( '__studio-' ) ) - .slice( 0, 3 ) - .map( ( c ) => '.' + CSS.escape( c ) ) - .join( '' ); - part += classes; - } - const parent = node.parentElement; - if ( parent ) { - /* Exclude our own host element when counting siblings — it - * lives directly under , so without this filter every - * top-level
child of picks up an off-by-one - * nth-of-type index and the agent looks up the wrong - * element. */ - const sameTagSiblings = Array.from( parent.children ).filter( - ( c ) => c.tagName === node.tagName && c.id !== HOST_ID - ); - if ( sameTagSiblings.length > 1 ) { - part += ':nth-of-type(' + ( sameTagSiblings.indexOf( node ) + 1 ) + ')'; - } - } - parts.unshift( part ); - node = parent; - if ( parts.length >= 6 ) break; - } - return parts.join( ' > ' ); - } - - function nearbyText( el ) { - const text = ( el.innerText || el.textContent || '' ) - .replace( /\s+/g, ' ' ) - .trim(); - return text.length > 200 ? text.slice( 0, 200 ) + '…' : text; - } - - function pickComputedStyles( el ) { - const cs = window.getComputedStyle( el ); - const keys = [ - 'color', - 'background-color', - 'font-size', - 'font-weight', - 'font-family', - 'line-height', - 'padding', - 'margin', - 'border', - 'display', - 'width', - 'height', - ]; - const out = {}; - for ( const k of keys ) { - out[ k ] = cs.getPropertyValue( k ); - } - return out; - } - - function uid() { - return 'a_' + Math.random().toString( 36 ).slice( 2, 10 ); - } - - /* -------------------------------------------------------------------- - * Shadow-DOM host so page CSS can't leak in and our CSS doesn't leak out. - * ------------------------------------------------------------------ */ - const oldHost = document.getElementById( HOST_ID ); - if ( oldHost ) oldHost.remove(); - const host = document.createElement( 'div' ); - host.id = HOST_ID; - host.style.cssText = - 'all: initial; position: fixed; inset: 0; pointer-events: none; z-index: 2147483647;'; - document.body.appendChild( host ); - const root = host.attachShadow( { mode: 'open' } ); - - const style = document.createElement( 'style' ); - style.textContent = ` + - '`' + - String.raw` - :host { all: initial; } - * { box-sizing: border-box; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } - .toolbar { - position: fixed; bottom: 1.25rem; right: 1.25rem; - display: flex; align-items: center; gap: 6px; - background: #1a1a1a; color: #fff; border-radius: 22px; - padding: 6px; box-shadow: 0 2px 8px rgba(0,0,0,0.2), 0 4px 16px rgba(0,0,0,0.1); - pointer-events: auto; - } - .toolbar button { - height: 32px; padding: 0 12px; - background: transparent; color: #fff; - border: none; border-radius: 16px; - font: 600 12px/1 inherit; cursor: pointer; - white-space: nowrap; - } - .toolbar button:hover { background: rgba(255,255,255,0.1); } - .toolbar button.primary { background: #fff; color: #1a1a1a; } - .toolbar button.primary:hover { background: #f0f0f0; } - .toolbar button.primary[disabled] { opacity: 0.5; cursor: default; } - .toolbar button.active { background: #2563eb; color: #fff; } - .toolbar .count { - min-width: 22px; height: 22px; padding: 0 6px; - display: inline-flex; align-items: center; justify-content: center; - background: rgba(255,255,255,0.15); color: #fff; - border-radius: 11px; font: 600 11px/1 inherit; - } - .highlight { - position: fixed; pointer-events: none; - border: 2px solid #2563eb; - background: rgba(37,99,235,0.1); - border-radius: 2px; - transition: all 80ms ease-out; - z-index: 1; - } - .marker { - position: fixed; pointer-events: auto; cursor: pointer; - width: 22px; height: 22px; - background: #2563eb; color: #fff; - border: 2px solid #fff; - border-radius: 50%; - box-shadow: 0 2px 6px rgba(0,0,0,0.3); - font: 700 11px/1 inherit; - display: inline-flex; align-items: center; justify-content: center; - z-index: 2; - } - .popup { - position: fixed; width: 320px; - background: #1a1a1a; color: #fff; - border-radius: 12px; - box-shadow: 0 4px 24px rgba(0,0,0,0.3), 0 0 0 1px rgba(255,255,255,0.08); - padding: 12px; z-index: 3; - pointer-events: auto; - display: flex; flex-direction: column; gap: 8px; - } - .popup .target { - font-size: 11px; color: rgba(255,255,255,0.5); - overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - } - .popup textarea { - width: 100%; min-height: 72px; resize: vertical; - background: rgba(255,255,255,0.05); color: #fff; - border: 1px solid rgba(255,255,255,0.15); border-radius: 8px; - padding: 8px; font: 13px/1.4 inherit; outline: none; - } - .popup textarea:focus { border-color: #2563eb; } - .popup .actions { display: flex; justify-content: flex-end; gap: 6px; } - .popup button { - padding: 6px 12px; border-radius: 16px; border: none; - font: 600 12px/1 inherit; cursor: pointer; - } - .popup .delete { - background: transparent; color: rgba(255,255,255,0.5); - margin-right: auto; - } - .popup .delete:hover { color: #ef4444; } - .popup .cancel { background: transparent; color: rgba(255,255,255,0.7); } - .popup .cancel:hover { background: rgba(255,255,255,0.08); } - .popup .save { background: #fff; color: #1a1a1a; } - .popup .save[disabled] { opacity: 0.4; cursor: default; } - .toast { - position: fixed; bottom: calc(1.25rem + 56px); right: 1.25rem; - background: rgba(26,26,26,0.95); color: #fff; - padding: 10px 14px; border-radius: 10px; - font: 500 12px/1.4 inherit; - pointer-events: auto; - } - ` + - '`' + - String.raw`; - root.appendChild( style ); - - /* -------------------------------------------------------------------- - * State - * ------------------------------------------------------------------ */ - let isPicking = false; - let hoveredEl = null; - let activePopup = null; /* { id?, target, comment } */ - let annotations = loadAnnotations(); - - /* -------------------------------------------------------------------- - * Render - * ------------------------------------------------------------------ */ - const layer = document.createElement( 'div' ); - root.appendChild( layer ); - - function render() { - layer.innerHTML = ''; - - /* Markers for existing annotations on the current path */ - const path = window.location.pathname; - const pageAnnotations = annotations.filter( ( a ) => a.pathname === path ); - pageAnnotations.forEach( ( ann, idx ) => { - let el = null; - try { - el = ann.selector ? document.querySelector( ann.selector ) : null; - } catch {} - if ( ! el ) return; - const r = el.getBoundingClientRect(); - const marker = document.createElement( 'div' ); - marker.className = 'marker'; - marker.textContent = String( idx + 1 ); - marker.style.left = r.left + r.width - 11 + 'px'; - marker.style.top = r.top - 11 + 'px'; - marker.title = ann.comment; - marker.addEventListener( 'click', ( e ) => { - e.stopPropagation(); - openPopupForAnnotation( ann ); - } ); - layer.appendChild( marker ); - } ); - - /* Hover highlight while picking */ - if ( isPicking && hoveredEl ) { - const r = hoveredEl.getBoundingClientRect(); - const hl = document.createElement( 'div' ); - hl.className = 'highlight'; - hl.style.left = r.left + 'px'; - hl.style.top = r.top + 'px'; - hl.style.width = r.width + 'px'; - hl.style.height = r.height + 'px'; - layer.appendChild( hl ); - } - - /* Active popup */ - if ( activePopup ) { - const popup = buildPopup( activePopup ); - layer.appendChild( popup ); - } - - /* Toolbar */ - const toolbar = document.createElement( 'div' ); - toolbar.className = 'toolbar'; - - const pickBtn = document.createElement( 'button' ); - pickBtn.textContent = isPicking ? 'Picking… click element' : 'Annotate'; - pickBtn.className = isPicking ? 'active' : ''; - pickBtn.addEventListener( 'click', () => { - isPicking = ! isPicking; - hoveredEl = null; - activePopup = null; - render(); - } ); - toolbar.appendChild( pickBtn ); - - if ( annotations.length > 0 ) { - const count = document.createElement( 'span' ); - count.className = 'count'; - count.textContent = String( annotations.length ); - count.title = annotations.length + ' annotation(s) total'; - toolbar.appendChild( count ); - } - - const doneBtn = document.createElement( 'button' ); - doneBtn.className = 'primary'; - doneBtn.textContent = 'Done'; - doneBtn.disabled = annotations.length === 0; - doneBtn.title = - annotations.length === 0 - ? 'Add at least one annotation first' - : 'Send annotations and return'; - doneBtn.addEventListener( 'click', () => { - if ( annotations.length === 0 ) return; - const sent = annotations.slice(); - /* Hand off to the CLI, then reset the inspector to its default - * idle state — annotations are no longer pending in the UI, - * picking mode is off, and nothing is left in localStorage to - * confuse the next session. */ - window.__studioAnnotateDone = { - capturedAt: Date.now(), - url: window.location.href, - annotations: sent, - }; - annotations = []; - saveAnnotations( annotations ); - isPicking = false; - hoveredEl = null; - activePopup = null; - startCountdownToast( sent.length, 10 ); - render(); - } ); - toolbar.appendChild( doneBtn ); - - layer.appendChild( toolbar ); - } - - function showToast( text ) { - const existing = root.querySelector( '.toast' ); - if ( existing ) existing.remove(); - const toast = document.createElement( 'div' ); - toast.className = 'toast'; - toast.textContent = text; - root.appendChild( toast ); - setTimeout( () => toast.remove(), 4000 ); - } - - /* Live countdown until the inspector-inject helper closes the browser. - * The actual close timer lives in the CLI process — we just mirror it - * visually so the user sees the window isn't disappearing on them. */ - function startCountdownToast( sentCount, totalSeconds ) { - const existing = root.querySelector( '.toast' ); - if ( existing ) existing.remove(); - const toast = document.createElement( 'div' ); - toast.className = 'toast'; - root.appendChild( toast ); - - let secondsLeft = totalSeconds; - const paint = () => { - toast.textContent = - 'Sent ' + - sentCount + - ' annotation(s) — closing in ' + - secondsLeft + - 's'; - }; - paint(); - const interval = setInterval( () => { - secondsLeft -= 1; - if ( secondsLeft <= 0 ) { - clearInterval( interval ); - toast.textContent = - 'Sent ' + sentCount + ' annotation(s) — closing now…'; - } else { - paint(); - } - }, 1000 ); - } - - function buildPopup( state ) { - const popup = document.createElement( 'div' ); - popup.className = 'popup'; - - /* Position relative to the target element if possible */ - let el = null; - try { - el = state.target.selector ? document.querySelector( state.target.selector ) : null; - } catch {} - if ( el ) { - const r = el.getBoundingClientRect(); - const popupWidth = 320; - const gap = 12; - const left = Math.min( - Math.max( 8, r.left + r.width / 2 - popupWidth / 2 ), - window.innerWidth - popupWidth - 8 - ); - let top = r.bottom + gap; - if ( top + 200 > window.innerHeight ) { - top = Math.max( 8, r.top - 200 - gap ); - } - popup.style.left = left + 'px'; - popup.style.top = top + 'px'; - } else { - popup.style.left = '50%'; - popup.style.top = '50%'; - popup.style.transform = 'translate(-50%, -50%)'; - } - - const target = document.createElement( 'div' ); - target.className = 'target'; - target.textContent = - state.target.tag + - ( state.target.nearbyText ? ' — ' + state.target.nearbyText : '' ); - popup.appendChild( target ); - - const ta = document.createElement( 'textarea' ); - ta.placeholder = 'What should change about this element?'; - ta.value = state.comment || ''; - ta.addEventListener( 'input', () => { - state.comment = ta.value; - save.disabled = ! state.comment.trim(); - } ); - popup.appendChild( ta ); - setTimeout( () => ta.focus(), 0 ); - - const actions = document.createElement( 'div' ); - actions.className = 'actions'; - - /* state.fromPicker is set only when the popup was opened by clicking - * a new element — used below by closePopup() to drop the user back - * into picking mode after Save/Cancel so they can chain - * annotations without re-clicking the Annotate button. */ - if ( state.id ) { - const del = document.createElement( 'button' ); - del.className = 'delete'; - del.textContent = 'Delete'; - del.addEventListener( 'click', () => { - annotations = annotations.filter( ( a ) => a.id !== state.id ); - saveAnnotations( annotations ); - activePopup = null; - render(); - } ); - actions.appendChild( del ); - } - - /* When the popup came from the picker we want to drop the user - * straight back into picking mode after Save/Cancel, so they can - * chain annotations without re-clicking "Annotate" each time. When - * the popup came from clicking an existing marker, leave picking - * mode off — they were editing one specific item. */ - const closePopup = () => { - activePopup = null; - if ( state.fromPicker ) { - isPicking = true; - } - render(); - }; - - const cancel = document.createElement( 'button' ); - cancel.className = 'cancel'; - cancel.textContent = 'Cancel'; - cancel.addEventListener( 'click', closePopup ); - actions.appendChild( cancel ); - - const save = document.createElement( 'button' ); - save.className = 'save'; - save.textContent = state.id ? 'Update' : 'Save'; - save.disabled = ! ( state.comment && state.comment.trim() ); - save.addEventListener( 'click', () => { - const trimmed = ( state.comment || '' ).trim(); - if ( ! trimmed ) return; - if ( state.id ) { - annotations = annotations.map( ( a ) => - a.id === state.id ? { ...a, comment: trimmed, updatedAt: Date.now() } : a - ); - } else { - annotations = annotations.concat( [ - { - id: uid(), - comment: trimmed, - selector: state.target.selector, - tag: state.target.tag, - nearbyText: state.target.nearbyText, - boundingBox: state.target.boundingBox, - computedStyles: state.target.computedStyles, - pathname: window.location.pathname, - url: window.location.href, - timestamp: Date.now(), - }, - ] ); - } - saveAnnotations( annotations ); - closePopup(); - } ); - actions.appendChild( save ); - - popup.appendChild( actions ); - - /* Stop propagation so popup interactions don't pick the element underneath */ - popup.addEventListener( 'click', ( e ) => e.stopPropagation() ); - popup.addEventListener( 'mousemove', ( e ) => e.stopPropagation() ); - - return popup; - } - - function openPopupForAnnotation( ann ) { - isPicking = false; - activePopup = { - id: ann.id, - comment: ann.comment, - target: { - selector: ann.selector, - tag: ann.tag, - nearbyText: ann.nearbyText, - boundingBox: ann.boundingBox, - computedStyles: ann.computedStyles, - }, - }; - render(); - } - - function openPopupForElement( el ) { - const r = el.getBoundingClientRect(); - isPicking = false; - activePopup = { - /* Tag this popup so closePopup() restores picking mode after - * Save/Cancel, letting the user chain annotations. */ - fromPicker: true, - comment: '', - target: { - selector: buildSelector( el ), - tag: el.tagName.toLowerCase(), - nearbyText: nearbyText( el ), - boundingBox: { x: r.x, y: r.y, width: r.width, height: r.height }, - computedStyles: pickComputedStyles( el ), - }, - }; - render(); - } - - /* -------------------------------------------------------------------- - * Picking interaction - * - * We listen at document level in the capture phase so we can intercept - * clicks before the page's own handlers fire — no accidental link - * navigation while annotating. - * ------------------------------------------------------------------ */ - function isOurElement( el ) { - return !! ( el && el.closest && el.closest( '#' + HOST_ID ) ); - } - - document.addEventListener( - 'mousemove', - ( e ) => { - if ( ! isPicking ) return; - if ( isOurElement( e.target ) ) { - hoveredEl = null; - render(); - return; - } - if ( hoveredEl !== e.target ) { - hoveredEl = e.target; - render(); - } - }, - true - ); - - document.addEventListener( - 'click', - ( e ) => { - if ( ! isPicking ) return; - if ( isOurElement( e.target ) ) return; - e.preventDefault(); - e.stopPropagation(); - openPopupForElement( e.target ); - }, - true - ); - - document.addEventListener( 'keydown', ( e ) => { - if ( e.key === 'Escape' ) { - if ( activePopup ) { - activePopup = null; - render(); - } else if ( isPicking ) { - isPicking = false; - hoveredEl = null; - render(); - } - } - } ); - - /* Re-render on scroll/resize so markers and highlights track the page */ - let scrollRaf = 0; - const onScrollOrResize = () => { - if ( scrollRaf ) return; - scrollRaf = requestAnimationFrame( () => { - scrollRaf = 0; - render(); - } ); - }; - window.addEventListener( 'scroll', onScrollOrResize, true ); - window.addEventListener( 'resize', onScrollOrResize ); - - render(); -} )(); -`; +export const INSPECTOR_PAGE_SCRIPT = createCliInspectorPageScript(); diff --git a/apps/ui/src/components/site-preview/index.tsx b/apps/ui/src/components/site-preview/index.tsx index ef5dce1740..caa50e2f89 100644 --- a/apps/ui/src/components/site-preview/index.tsx +++ b/apps/ui/src/components/site-preview/index.tsx @@ -1,14 +1,7 @@ import { getSiteOperationLabel } from '@studio/common/lib/site-operation-labels'; import { useQuery } from '@tanstack/react-query'; import { __, sprintf } from '@wordpress/i18n'; -import { - chevronDown, - chevronLeft, - chevronRight, - Icon, - moreVertical, - pencil, -} from '@wordpress/icons'; +import { chevronDown, chevronLeft, chevronRight, Icon, moreVertical } from '@wordpress/icons'; import { ariaKeyShortcut, displayShortcut, isAppleOS, isKeyboardEvent } from '@wordpress/keycodes'; import { Button, IconButton, Tooltip } from '@wordpress/ui'; import { clsx } from 'clsx'; @@ -28,7 +21,7 @@ import { import { useTrafficLightSpace } from '@/hooks/use-traffic-light-space'; import { useWindowControlsOverlay } from '@/hooks/use-window-controls-overlay'; import { getSiteUrl } from '@/lib/get-site-url'; -import { playIcon, refreshIcon } from '@/lib/icons'; +import { annotationIcon, playIcon, refreshIcon } from '@/lib/icons'; import { DATABASE_HOME_PATH, getPathFromPreviewUrl, @@ -43,7 +36,7 @@ import { import { INSPECTOR_BRIDGE_PREFIX, INSPECTOR_COMMAND_EVENT, - INSPECTOR_PAGE_SCRIPT, + createInspectorPageScript, } from './inspector-script'; import styles from './style.module.css'; import type { Annotation } from './types'; @@ -79,12 +72,15 @@ interface SitePreviewProps { interface InspectorEvent { type: 'annotations-updated' | 'browser-command' | 'done' | 'state'; + bridgeToken?: string; annotations?: Annotation[]; isPicking?: boolean; annotationCount?: number; command?: PreviewShortcutCommandType; } +const MAX_INSPECTOR_BRIDGE_MESSAGE_LENGTH = 1_100_000; + interface InspectorState { ready: boolean; isPicking: boolean; @@ -565,7 +561,7 @@ function PreviewAnnotationControls( { variant="minimal" tone="neutral" size="small" - icon={ pencil } + icon={ annotationIcon } label={ toggleLabel } disabled={ disabled } aria-pressed={ isPicking } @@ -587,7 +583,7 @@ function PreviewAnnotationControls( { { hasPending ? (
{ /* Two commands to offer, so it becomes a split button matching the - "Open in…" control beside it: the pencil still toggles directly, + "Open in…" control beside it: the annotation icon still toggles directly, the chevron opens the pair. Modal for the same reason as the overflow menu — the webview swallows outside clicks, so the backdrop is what dismisses it. */ } @@ -608,7 +604,7 @@ function PreviewAnnotationControls( { /> } > - + }> { toggleLabel } @@ -1298,6 +1294,7 @@ function WebviewSurface( { const domReadyRef = useRef( false ); const currentUrlRef = useRef( url ); const storedAnnotationsRef = useRef< Annotation[] >( [] ); + const inspectorBridgeTokenRef = useRef( globalThis.crypto.randomUUID() ); const lastReloadNonceRef = useRef( reloadNonce ); const progressTimerRef = useRef< ReturnType< typeof setInterval > | null >( null ); const progressResetTimerRef = useRef< ReturnType< typeof setTimeout > | null >( null ); @@ -1419,7 +1416,10 @@ function WebviewSurface( { const preload = stored.length > 0 ? `window.__studioInspectorState=${ JSON.stringify( stored ) };` : ''; webview - .executeJavaScript( preload + INSPECTOR_PAGE_SCRIPT, false ) + .executeJavaScript( + preload + createInspectorPageScript( inspectorBridgeTokenRef.current ), + false + ) .then( () => { onInspectorStateRef.current?.( { ready: true, @@ -1437,6 +1437,7 @@ function WebviewSurface( { const consoleEvent = event as WebviewConsoleEvent; if ( typeof consoleEvent.message !== 'string' ) return; if ( ! consoleEvent.message.startsWith( INSPECTOR_BRIDGE_PREFIX ) ) return; + if ( consoleEvent.message.length > MAX_INSPECTOR_BRIDGE_MESSAGE_LENGTH ) return; let parsed: InspectorEvent | null = null; try { parsed = JSON.parse( consoleEvent.message.slice( INSPECTOR_BRIDGE_PREFIX.length ) ); @@ -1444,6 +1445,7 @@ function WebviewSurface( { return; } if ( ! parsed ) return; + if ( parsed.bridgeToken !== inspectorBridgeTokenRef.current ) return; if ( parsed.type === 'browser-command' ) { if ( isPreviewShortcutCommand( parsed.command ) ) { onBrowserCommandRef.current?.( parsed.command ); @@ -1555,7 +1557,10 @@ function WebviewSurface( { if ( ! ready || ! inspectorCommand ) return; const webview = ref.current as WebviewTag | null; if ( ! webview ) return; - const detail = JSON.stringify( { type: inspectorCommand.type } ); + const detail = JSON.stringify( { + type: inspectorCommand.type, + bridgeToken: inspectorBridgeTokenRef.current, + } ); webview .executeJavaScript( `window.dispatchEvent(new CustomEvent(${ JSON.stringify( diff --git a/apps/ui/src/components/site-preview/inspector-script.test.ts b/apps/ui/src/components/site-preview/inspector-script.test.ts index 42fe1060b5..978aade6bc 100644 --- a/apps/ui/src/components/site-preview/inspector-script.test.ts +++ b/apps/ui/src/components/site-preview/inspector-script.test.ts @@ -1,132 +1,296 @@ +import { createCliInspectorPageScript } from '@studio/common/ai/inspector-page-script'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { INSPECTOR_BRIDGE_PREFIX, INSPECTOR_COMMAND_EVENT, - INSPECTOR_PAGE_SCRIPT, + createInspectorPageScript, } from './inspector-script'; -describe( 'site preview inspector sessions', () => { +const BRIDGE_TOKEN = 'test-inspector-bridge-token'; + +describe( 'site preview inspector', () => { afterEach( () => { - // Without this the previous inspector's document listeners stay live and - // answer the next test's commands alongside the instance under test. ( window as Window & { __studioInspectorDispose?: () => void } ).__studioInspectorDispose?.(); vi.restoreAllMocks(); document.body.replaceChildren(); delete ( window as Window & { __studioInspectorState?: unknown[] } ).__studioInspectorState; + delete ( window as Window & { __studioAnnotateDone?: unknown } ).__studioAnnotateDone; + localStorage.clear(); } ); - it( 'saves several notes without leaving annotation mode', () => { + it( 'keeps saved notes when picking is switched off', () => { const log = vi.spyOn( console, 'log' ).mockImplementation( () => undefined ); - document.body.innerHTML = '

First

Second

'; + ( window as Window & { __studioInspectorState?: unknown[] } ).__studioInspectorState = [ + { + id: 'saved', + comment: 'Saved note', + tag: 'h1', + pathname: window.location.pathname, + documentRect: { left: 10, top: 10, width: 100, height: 40 }, + }, + ]; + + new Function( createInspectorPageScript( BRIDGE_TOKEN ) )(); + dispatchInspectorCommand( 'toggle-picking' ); + dispatchInspectorCommand( 'toggle-picking' ); + + expect( latestBridgeMessage( log, 'state' ) ).toMatchObject( { + isPicking: false, + annotationCount: 1, + } ); + expect( + ( window as Window & { __studioInspectorState?: unknown[] } ).__studioInspectorState + ).toHaveLength( 1 ); + } ); + + it( 'keeps picking active while composing and after saving annotations', () => { + const log = vi.spyOn( console, 'log' ).mockImplementation( () => undefined ); + document.body.innerHTML = + '

First target

Second target

'; const first = document.querySelector( '#first' ) as HTMLElement; const second = document.querySelector( '#second' ) as HTMLElement; - vi.spyOn( first, 'getBoundingClientRect' ).mockReturnValue( rect( 10, 10 ) ); - vi.spyOn( second, 'getBoundingClientRect' ).mockReturnValue( rect( 10, 80 ) ); + vi.spyOn( first, 'getBoundingClientRect' ).mockReturnValue( { + x: 10, + y: 0, + top: 0, + right: 210, + bottom: 50, + left: 10, + width: 200, + height: 50, + toJSON: () => ( {} ), + } ); + vi.spyOn( second, 'getBoundingClientRect' ).mockReturnValue( { + x: 10, + y: 100, + top: 100, + right: 210, + bottom: 140, + left: 10, + width: 200, + height: 40, + toJSON: () => ( {} ), + } ); + + new Function( createInspectorPageScript( BRIDGE_TOKEN ) )(); + const host = document.querySelector( '#__studio-inspector-host' ) as HTMLElement; + const root = host.shadowRoot as ShadowRoot; + const command = ( type: string ) => + window.dispatchEvent( + new CustomEvent( INSPECTOR_COMMAND_EVENT, { + detail: { type, bridgeToken: BRIDGE_TOKEN }, + } ) + ); + const stateMessages = () => + log.mock.calls + .map( ( [ message ] ) => message ) + .filter( + ( message ): message is string => + typeof message === 'string' && message.startsWith( INSPECTOR_BRIDGE_PREFIX ) + ) + .map( ( message ) => JSON.parse( message.slice( INSPECTOR_BRIDGE_PREFIX.length ) ) ) + .filter( ( message ) => message.type === 'state' ); + + window.dispatchEvent( + new CustomEvent( INSPECTOR_COMMAND_EVENT, { + detail: { type: 'toggle-picking', bridgeToken: 'untrusted-page-token' }, + } ) + ); + first.dispatchEvent( new MouseEvent( 'click', { bubbles: true, cancelable: true } ) ); + expect( root.querySelector( '.popup' ) ).toBeNull(); - new Function( INSPECTOR_PAGE_SCRIPT )(); - const root = ( document.querySelector( '#__studio-inspector-host' ) as HTMLElement ) - .shadowRoot as ShadowRoot; command( 'toggle-picking' ); + first.dispatchEvent( new MouseEvent( 'mousemove', { bubbles: true } ) ); first.dispatchEvent( new MouseEvent( 'click', { bubbles: true, cancelable: true } ) ); + expect( root.querySelector( '.popup' ) ).not.toBeNull(); + expect( root.querySelector( '.highlight' ) ).not.toBeNull(); + expect( root.querySelectorAll( '.scrim' ) ).toHaveLength( 4 ); + expect( document.documentElement ).toHaveStyle( { overflow: 'hidden' } ); + const style = root.querySelector( 'style' )?.textContent ?? ''; + expect( style ).toContain( 'border-radius: 8px 8px 20px 8px' ); + expect( style ).toContain( 'backdrop-filter: blur(20px)' ); + expect( style ).toContain( 'min-height: 24px' ); + expect( style ).toContain( '0 0 0 1px rgba(0,0,0,0.9)' ); + expect( style ).toContain( 'background: rgba(0,0,0,0.52)' ); + const firstTextarea = root.querySelector( 'textarea' ) as HTMLTextAreaElement; - firstTextarea.value = 'First note'; + firstTextarea.value = 'First line'; + firstTextarea.dispatchEvent( new InputEvent( 'input', { bubbles: true } ) ); + firstTextarea.setSelectionRange( firstTextarea.value.length, firstTextarea.value.length ); + firstTextarea.dispatchEvent( + new KeyboardEvent( 'keydown', { key: 'Enter', metaKey: true, bubbles: true } ) + ); + expect( firstTextarea.value ).toBe( 'First line\n' ); + firstTextarea.value += 'Second line'; firstTextarea.dispatchEvent( new InputEvent( 'input', { bubbles: true } ) ); firstTextarea.dispatchEvent( new KeyboardEvent( 'keydown', { key: 'Enter', bubbles: true, cancelable: true } ) ); expect( root.querySelector( '.popup' ) ).toBeNull(); + expect( root.querySelectorAll( '.scrim' ) ).toHaveLength( 0 ); + expect( document.documentElement ).toHaveStyle( { overflow: '' } ); expect( root.querySelectorAll( '.marker' ) ).toHaveLength( 1 ); - expect( latestState( log ) ).toMatchObject( { isPicking: true, annotationCount: 1 } ); + expect( root.querySelector( '.marker' ) ).toHaveStyle( { top: '12px' } ); + expect( root.querySelectorAll( '.annotation-highlight' ) ).toHaveLength( 1 ); + expect( stateMessages().at( -1 ) ).toMatchObject( { + isPicking: true, + annotationCount: 1, + } ); second.dispatchEvent( new MouseEvent( 'click', { bubbles: true, cancelable: true } ) ); + expect( root.querySelector( '.popup' ) ).not.toBeNull(); + expect( root.querySelector( '.highlight' ) ).not.toBeNull(); const secondTextarea = root.querySelector( 'textarea' ) as HTMLTextAreaElement; - secondTextarea.value = 'First line'; - secondTextarea.dispatchEvent( new InputEvent( 'input', { bubbles: true } ) ); - secondTextarea.setSelectionRange( secondTextarea.value.length, secondTextarea.value.length ); - secondTextarea.dispatchEvent( - new KeyboardEvent( 'keydown', { - key: 'Enter', - metaKey: true, - bubbles: true, - cancelable: true, - } ) - ); - expect( secondTextarea.value ).toBe( 'First line\n' ); - secondTextarea.value += 'Second line'; + secondTextarea.value = 'Second note'; secondTextarea.dispatchEvent( new InputEvent( 'input', { bubbles: true } ) ); + expect( root.querySelector( '.send-to-chat' ) ).not.toBeNull(); command( 'submit' ); - const done = bridgeMessages( log ).find( ( message ) => message.type === 'done' ); - expect( done?.annotations ).toEqual( + expect( root.querySelector( '.popup' ) ).toBeNull(); + expect( root.querySelectorAll( '.marker' ) ).toHaveLength( 0 ); + expect( root.querySelectorAll( '.annotation-highlight' ) ).toHaveLength( 0 ); + expect( stateMessages().at( -1 ) ).toMatchObject( { + isPicking: false, + annotationCount: 0, + } ); + const doneMessage = log.mock.calls + .map( ( [ message ] ) => message ) + .filter( + ( message ): message is string => + typeof message === 'string' && message.startsWith( INSPECTOR_BRIDGE_PREFIX ) + ) + .map( ( message ) => JSON.parse( message.slice( INSPECTOR_BRIDGE_PREFIX.length ) ) ) + .find( ( message ) => message.type === 'done' ); + expect( doneMessage.annotations ).toEqual( expect.arrayContaining( [ - expect.objectContaining( { comment: 'First note' } ), expect.objectContaining( { comment: 'First line\nSecond line' } ), + expect.objectContaining( { comment: 'Second note' } ), ] ) ); - expect( latestState( log ) ).toMatchObject( { isPicking: false, annotationCount: 0 } ); } ); - it( 'keeps saved notes when annotation mode is switched off', () => { - const log = vi.spyOn( console, 'log' ).mockImplementation( () => undefined ); - seedSavedNote(); + it( 'cycles through overlapping elements at the selected point', () => { + vi.spyOn( console, 'log' ).mockImplementation( () => undefined ); + document.body.innerHTML = + '
Front container
Floating art'; + const front = document.querySelector( '#front' ) as HTMLElement; + const behind = document.querySelector( '#behind' ) as HTMLElement; + const frontRect = { + x: 10, + y: 10, + top: 10, + right: 210, + bottom: 210, + left: 10, + width: 200, + height: 200, + toJSON: () => ( {} ), + }; + const behindRect = { + ...frontRect, + x: 40, + y: 40, + top: 40, + right: 140, + bottom: 140, + left: 40, + width: 100, + height: 100, + }; + vi.spyOn( front, 'getBoundingClientRect' ).mockReturnValue( frontRect ); + vi.spyOn( behind, 'getBoundingClientRect' ).mockReturnValue( behindRect ); - new Function( INSPECTOR_PAGE_SCRIPT )(); - command( 'toggle-picking' ); - document.dispatchEvent( - new KeyboardEvent( 'keydown', { key: 'Escape', bubbles: true, cancelable: true } ) + new Function( createInspectorPageScript( BRIDGE_TOKEN ) )(); + const root = ( document.querySelector( '#__studio-inspector-host' ) as HTMLElement ) + .shadowRoot as ShadowRoot; + window.dispatchEvent( + new CustomEvent( INSPECTOR_COMMAND_EVENT, { + detail: { type: 'toggle-picking', bridgeToken: BRIDGE_TOKEN }, + } ) + ); + front.dispatchEvent( + new MouseEvent( 'click', { bubbles: true, cancelable: true, clientX: 80, clientY: 80 } ) ); - expect( latestState( log ) ).toMatchObject( { isPicking: false, annotationCount: 1 } ); - expect( - ( window as Window & { __studioInspectorState?: unknown[] } ).__studioInspectorState - ).toHaveLength( 1 ); - } ); - - it( 'reopens an existing note without turning picking back on', () => { - const log = vi.spyOn( console, 'log' ).mockImplementation( () => undefined ); - seedSavedNote(); + expect( root.querySelector( '.layer-count' )?.textContent ).toBe( '1/2' ); + const initialPopup = root.querySelector( '.popup' ) as HTMLElement; + const initialPosition = { + left: initialPopup.style.left, + top: initialPopup.style.top, + }; + ( + root.querySelector( '[aria-label="Select next element at this point"]' ) as HTMLElement + ).click(); + expect( root.querySelector( '.target' )?.textContent ).toBe( 'img#behind' ); + expect( root.querySelector( '.layer-count' )?.textContent ).toBe( '2/2' ); + expect( root.querySelector( '.popup' ) ).toHaveStyle( initialPosition ); - new Function( INSPECTOR_PAGE_SCRIPT )(); - const root = ( document.querySelector( '#__studio-inspector-host' ) as HTMLElement ) - .shadowRoot as ShadowRoot; - const marker = root.querySelector( '.marker' ) as HTMLElement; - marker.dispatchEvent( new MouseEvent( 'click', { bubbles: true, cancelable: true } ) ); + const handle = root.querySelector( '.target-row' ) as HTMLElement; + const popup = root.querySelector( '.popup' ) as HTMLElement; + const startingLeft = Number.parseFloat( popup.style.left ); + handle.dispatchEvent( + new MouseEvent( 'mousedown', { bubbles: true, button: 0, clientX: 100, clientY: 100 } ) + ); + window.dispatchEvent( + new MouseEvent( 'mousemove', { bubbles: true, clientX: 130, clientY: 120 } ) + ); + window.dispatchEvent( new MouseEvent( 'mouseup', { bubbles: true } ) ); + expect( Number.parseFloat( popup.style.left ) ).toBe( startingLeft + 30 ); - expect( root.querySelector( '.popup' ) ).toBeInTheDocument(); - expect( latestState( log ) ).toMatchObject( { isPicking: false, annotationCount: 1 } ); + dispatchInspectorCommand( 'toggle-picking' ); } ); - it( 'submits saved notes when an empty draft popup is open', () => { + it( 'submits saved notes when an empty draft is open', () => { const log = vi.spyOn( console, 'log' ).mockImplementation( () => undefined ); document.body.innerHTML = '

Draft

'; - const draft = document.querySelector( '#draft' ) as HTMLElement; - vi.spyOn( draft, 'getBoundingClientRect' ).mockReturnValue( rect( 10, 10 ) ); - seedSavedNote(); + const target = document.querySelector( '#draft' ) as HTMLElement; + vi.spyOn( target, 'getBoundingClientRect' ).mockReturnValue( rect( 10, 10 ) ); + ( window as Window & { __studioInspectorState?: unknown[] } ).__studioInspectorState = [ + { + id: 'saved', + comment: 'Saved note', + pathname: window.location.pathname, + documentRect: { left: 10, top: 10, width: 100, height: 40 }, + }, + ]; - new Function( INSPECTOR_PAGE_SCRIPT )(); - command( 'toggle-picking' ); - draft.dispatchEvent( new MouseEvent( 'click', { bubbles: true, cancelable: true } ) ); - command( 'submit' ); + new Function( createInspectorPageScript( BRIDGE_TOKEN ) )(); + dispatchInspectorCommand( 'toggle-picking' ); + target.dispatchEvent( + new MouseEvent( 'click', { + bubbles: true, + cancelable: true, + clientX: 20, + clientY: 20, + } ) + ); + dispatchInspectorCommand( 'submit' ); - const done = bridgeMessages( log ).find( ( message ) => message.type === 'done' ); - expect( done?.annotations ).toEqual( [ expect.objectContaining( { comment: 'Saved note' } ) ] ); - expect( latestState( log ) ).toMatchObject( { isPicking: false, annotationCount: 0 } ); + expect( latestBridgeMessage( log, 'done' )?.annotations ).toEqual( [ + expect.objectContaining( { comment: 'Saved note' } ), + ] ); } ); - it( 'does not save while text input is being composed', () => { + it( 'does not save a note while text input is being composed', () => { vi.spyOn( console, 'log' ).mockImplementation( () => undefined ); document.body.innerHTML = '

Composing

'; const target = document.querySelector( '#composing' ) as HTMLElement; vi.spyOn( target, 'getBoundingClientRect' ).mockReturnValue( rect( 10, 10 ) ); - new Function( INSPECTOR_PAGE_SCRIPT )(); + new Function( createInspectorPageScript( BRIDGE_TOKEN ) )(); + dispatchInspectorCommand( 'toggle-picking' ); + target.dispatchEvent( + new MouseEvent( 'click', { + bubbles: true, + cancelable: true, + clientX: 20, + clientY: 20, + } ) + ); const root = ( document.querySelector( '#__studio-inspector-host' ) as HTMLElement ) .shadowRoot as ShadowRoot; - command( 'toggle-picking' ); - target.dispatchEvent( new MouseEvent( 'click', { bubbles: true, cancelable: true } ) ); - const textarea = root.querySelector( 'textarea' ) as HTMLTextAreaElement; textarea.value = '入力中'; textarea.dispatchEvent( new InputEvent( 'input', { bubbles: true } ) ); @@ -142,22 +306,135 @@ describe( 'site preview inspector sessions', () => { expect( root.querySelector( '.popup' ) ).toBeInTheDocument(); expect( root.querySelectorAll( '.marker' ) ).toHaveLength( 0 ); } ); + + it( 'uses the shared inspector UI and persistent batch in the CLI browser', () => { + vi.spyOn( console, 'log' ).mockImplementation( () => undefined ); + localStorage.setItem( + 'studio-inspector-annotations-v1', + JSON.stringify( [ + { + id: 'persisted-note', + comment: 'Persisted note', + pathname: window.location.pathname, + documentRect: { left: 10, top: 10, width: 100, height: 40 }, + }, + ] ) + ); + + new Function( createCliInspectorPageScript() )(); + const root = ( document.querySelector( '#__studio-inspector-host' ) as HTMLElement ) + .shadowRoot as ShadowRoot; + + expect( root.querySelector( '.toolbar' ) ).not.toBeNull(); + expect( root.querySelector( '.popup' ) ).toBeNull(); + expect( root.querySelectorAll( '.marker' ) ).toHaveLength( 1 ); + expect( root.querySelectorAll( '.annotation-highlight' ) ).toHaveLength( 1 ); + expect( root.querySelector( '.submit' ) ).toHaveTextContent( 'Send to agent' ); + } ); + + it( 'keeps CLI notes when annotation mode is switched off', () => { + vi.spyOn( console, 'log' ).mockImplementation( () => undefined ); + localStorage.setItem( + 'studio-inspector-annotations-v1', + JSON.stringify( [ + { + id: 'saved-cli-note', + comment: 'Saved CLI note', + pathname: window.location.pathname, + documentRect: { left: 10, top: 10, width: 100, height: 40 }, + }, + ] ) + ); + + new Function( createCliInspectorPageScript() )(); + const root = ( document.querySelector( '#__studio-inspector-host' ) as HTMLElement ) + .shadowRoot as ShadowRoot; + ( root.querySelector( '.toolbar button:not(.submit)' ) as HTMLButtonElement ).click(); + expect( root.querySelector( '.toolbar button:not(.submit)' ) ).toHaveTextContent( + 'Stop annotating' + ); + ( root.querySelector( '.toolbar button:not(.submit)' ) as HTMLButtonElement ).click(); + expect( root.querySelector( '.toolbar button:not(.submit)' ) ).toHaveTextContent( 'Annotate' ); + expect( + JSON.parse( localStorage.getItem( 'studio-inspector-annotations-v1' ) ?? '[]' ) + ).toHaveLength( 1 ); + } ); + + it( 'saves and submits several notes from the CLI browser', () => { + vi.spyOn( console, 'log' ).mockImplementation( () => undefined ); + document.body.innerHTML = '

First

Second

'; + const first = document.querySelector( '#first-cli' ) as HTMLElement; + const second = document.querySelector( '#second-cli' ) as HTMLElement; + vi.spyOn( first, 'getBoundingClientRect' ).mockReturnValue( rect( 10, 10 ) ); + vi.spyOn( second, 'getBoundingClientRect' ).mockReturnValue( rect( 10, 80 ) ); + + new Function( createCliInspectorPageScript() )(); + const root = ( document.querySelector( '#__studio-inspector-host' ) as HTMLElement ) + .shadowRoot as ShadowRoot; + ( root.querySelector( '.toolbar button:not(.submit)' ) as HTMLButtonElement ).click(); + + for ( const [ target, comment ] of [ + [ first, 'First CLI note' ], + [ second, 'Second CLI note' ], + ] as const ) { + target.dispatchEvent( + new MouseEvent( 'click', { + bubbles: true, + cancelable: true, + clientX: 20, + clientY: target === first ? 20 : 90, + } ) + ); + const textarea = root.querySelector( 'textarea' ) as HTMLTextAreaElement; + textarea.value = comment; + textarea.dispatchEvent( new InputEvent( 'input', { bubbles: true } ) ); + textarea.dispatchEvent( + new KeyboardEvent( 'keydown', { + key: 'Enter', + bubbles: true, + cancelable: true, + } ) + ); + } + + ( root.querySelector( '.submit' ) as HTMLButtonElement ).click(); + + const result = ( + window as Window & { + __studioAnnotateDone?: { annotations: Array< { comment: string } > }; + } + ).__studioAnnotateDone; + expect( result?.annotations ).toEqual( [ + expect.objectContaining( { comment: 'First CLI note' } ), + expect.objectContaining( { comment: 'Second CLI note' } ), + ] ); + expect( + JSON.parse( localStorage.getItem( 'studio-inspector-annotations-v1' ) ?? '[]' ) + ).toEqual( [] ); + } ); } ); -function seedSavedNote() { - ( window as Window & { __studioInspectorState?: unknown[] } ).__studioInspectorState = [ - { - id: 'saved', - comment: 'Saved note', - tag: 'h1', - path: window.location.pathname + window.location.search, - documentRect: { left: 10, top: 10, width: 100, height: 40 }, - }, - ]; +function dispatchInspectorCommand( type: string ) { + window.dispatchEvent( + new CustomEvent( INSPECTOR_COMMAND_EVENT, { + detail: { type, bridgeToken: BRIDGE_TOKEN }, + } ) + ); } -function command( type: string ) { - window.dispatchEvent( new CustomEvent( INSPECTOR_COMMAND_EVENT, { detail: { type } } ) ); +function latestBridgeMessage( + log: { mock: { calls: unknown[][] } }, + type: string +): { type: string; annotations?: unknown[]; isPicking?: boolean } | undefined { + return log.mock.calls + .map( ( call ) => call[ 0 ] ) + .filter( + ( message ): message is string => + typeof message === 'string' && message.startsWith( INSPECTOR_BRIDGE_PREFIX ) + ) + .map( ( message ) => JSON.parse( message.slice( INSPECTOR_BRIDGE_PREFIX.length ) ) ) + .filter( ( message ) => message.type === type ) + .at( -1 ); } function rect( left: number, top: number ): DOMRect { @@ -173,23 +450,3 @@ function rect( left: number, top: number ): DOMRect { toJSON: () => ( {} ), } as DOMRect; } - -interface ConsoleLogSpy { - mock: { calls: unknown[][] }; -} - -function bridgeMessages( log: ConsoleLogSpy ): Array< Record< string, unknown > > { - return log.mock.calls - .map( ( call ) => call[ 0 ] ) - .filter( - ( message ): message is string => - typeof message === 'string' && message.startsWith( INSPECTOR_BRIDGE_PREFIX ) - ) - .map( ( message ) => JSON.parse( message.slice( INSPECTOR_BRIDGE_PREFIX.length ) ) ); -} - -function latestState( log: ConsoleLogSpy ) { - return bridgeMessages( log ) - .filter( ( message ) => message.type === 'state' ) - .at( -1 ); -} diff --git a/apps/ui/src/components/site-preview/inspector-script.ts b/apps/ui/src/components/site-preview/inspector-script.ts index c607537b81..2abdb2de05 100644 --- a/apps/ui/src/components/site-preview/inspector-script.ts +++ b/apps/ui/src/components/site-preview/inspector-script.ts @@ -1,681 +1,5 @@ -/** - * Annotation inspector injected into the site-preview `` via - * `webview.executeJavaScript()`. - * - * Runs in the cross-origin guest page so it uses vanilla DOM in a Shadow DOM - * root — React isn't loaded there. Communicates with the host renderer via a - * structured `console.log` line that the host receives through the webview's - * `console-message` event: - * guest -> host: `__studio-inspector__:{ "type": "done", ... }` - * - * The same bridge also reports picking/annotation-count state changes and - * forwards browser keyboard shortcuts (reload, back, forward) pressed while - * focus is inside the guest page, so the host toolbar can handle them: - * guest -> host: `__studio-inspector__:{ "type": "state", ... }` - * guest -> host: `__studio-inspector__:{ "type": "browser-command", ... }` - * - * The annotation controls live in the host toolbar (not in the page), and - * drive the inspector by dispatching `INSPECTOR_COMMAND_EVENT` custom events - * on the guest `window` via `webview.executeJavaScript()`: - * host -> guest: `{ "type": "toggle-picking" | "submit" | "report-state" }` - * - * Layout strategy: markers and the picking highlight use `position: absolute` - * anchored at *document* coordinates (viewport rect + scroll offset). They - * scroll with the page automatically — no scroll listener, no rAF loop. The - * popup uses `position: fixed` so it stays in the viewport. - */ - -export const INSPECTOR_BRIDGE_PREFIX = '__studio-inspector__:'; -export const INSPECTOR_COMMAND_EVENT = '__studio-inspector-command'; - -export const INSPECTOR_PAGE_SCRIPT = - String.raw` -( () => { - if ( window.__studioInspectorMounted ) { - window.dispatchEvent( - new CustomEvent( '` + - INSPECTOR_COMMAND_EVENT + - String.raw`', { detail: { type: 'report-state' } } ) - ); - return; - } - /* A stale instance can outlive its host element (the mount flag is per - * document, its listeners are not), so retire it before taking over. */ - if ( typeof window.__studioInspectorDispose === 'function' ) { - window.__studioInspectorDispose(); - } - window.__studioInspectorMounted = true; - const teardown = new AbortController(); - - const BRIDGE_PREFIX = '` + - INSPECTOR_BRIDGE_PREFIX + - String.raw`'; - const COMMAND_EVENT = '` + - INSPECTOR_COMMAND_EVENT + - String.raw`'; - const HOST_ID = '__studio-inspector-host'; - - function send( payload ) { - try { - console.log( BRIDGE_PREFIX + JSON.stringify( payload ) ); - } catch ( err ) { - /* JSON.stringify can fail on cycles; the host treats missing - * messages as no-ops, so we swallow rather than crash the page. */ - } - } - - function isApplePlatform() { - return /mac|iphone|ipad|ipod/i.test( navigator.platform || navigator.userAgent || '' ); - } - - function isTextEntryTarget( el ) { - if ( ! el || el.nodeType !== 1 ) return false; - if ( el.isContentEditable ) return true; - const tag = el.tagName.toLowerCase(); - return tag === 'input' || tag === 'textarea' || tag === 'select'; - } - - function getBrowserShortcutCommand( event ) { - if ( event.defaultPrevented || event.repeat ) return null; - const apple = isApplePlatform(); - if ( event.key === 'ArrowLeft' || event.key === 'ArrowRight' ) { - /* Layout-independent back/forward aliases: the bracket chords need - * Option/AltGr on many European layouts. Skipped while editing text - * to keep native caret movement. */ - const hasNavModifier = apple - ? event.metaKey && ! event.ctrlKey && ! event.altKey - : event.altKey && ! event.ctrlKey && ! event.metaKey; - if ( ! hasNavModifier || event.shiftKey || isTextEntryTarget( event.target ) ) return null; - return event.key === 'ArrowLeft' ? 'back' : 'forward'; - } - if ( event.altKey ) return null; - const hasPrimaryModifier = apple ? event.metaKey : event.ctrlKey; - if ( ! hasPrimaryModifier ) return null; - const key = event.key.toLowerCase(); - /* The host owns full preview, but in that mode this page covers most of - * the window — so the chord is caught here and forwarded back. */ - if ( event.shiftKey ) { - if ( key === 'f' ) return 'full-preview'; - return key === 'r' ? 'reload' : null; - } - if ( key === 'r' ) return 'reload'; - if ( key === '[' ) return 'back'; - if ( key === ']' ) return 'forward'; - return null; - } - - function buildSelector( el ) { - if ( ! el || el.nodeType !== 1 ) return ''; - if ( el.id ) return '#' + CSS.escape( el.id ); - const parts = []; - let node = el; - while ( node && node.nodeType === 1 && node !== document.documentElement ) { - let part = node.tagName.toLowerCase(); - if ( node.classList && node.classList.length ) { - const classes = Array.from( node.classList ) - .filter( ( c ) => ! c.startsWith( '__studio-' ) ) - .slice( 0, 3 ) - .map( ( c ) => '.' + CSS.escape( c ) ) - .join( '' ); - part += classes; - } - const parent = node.parentElement; - if ( parent ) { - const sameTagSiblings = Array.from( parent.children ).filter( - ( c ) => c.tagName === node.tagName - ); - if ( sameTagSiblings.length > 1 ) { - part += ':nth-of-type(' + ( sameTagSiblings.indexOf( node ) + 1 ) + ')'; - } - } - parts.unshift( part ); - node = parent; - if ( parts.length >= 6 ) break; - } - return parts.join( ' > ' ); - } - - function nearbyText( el ) { - const text = ( el.innerText || el.textContent || '' ) - .replace( /\s+/g, ' ' ) - .trim(); - return text.length > 200 ? text.slice( 0, 200 ) + '…' : text; - } - - function pickComputedStyles( el ) { - const cs = window.getComputedStyle( el ); - const keys = [ - 'color', 'background-color', 'font-size', 'font-weight', - 'font-family', 'line-height', 'padding', 'margin', - 'border', 'display', 'width', 'height', - ]; - const out = {}; - for ( const k of keys ) { - out[ k ] = cs.getPropertyValue( k ); - } - return out; - } - - function uid() { - return 'a_' + Math.random().toString( 36 ).slice( 2, 10 ); - } - - function documentRect( el ) { - const r = el.getBoundingClientRect(); - return { - left: r.left + window.scrollX, - top: r.top + window.scrollY, - width: r.width, - height: r.height, - }; - } - - /* ------------------------------------------------------------------ - * Shadow DOM host. The host is \`position: absolute; top: 0; left: 0\` - * with zero size — this anchors all absolutely-positioned descendants - * at the document origin so their coordinates are document-relative - * (and therefore scroll naturally with the page). - * ---------------------------------------------------------------- */ - const oldHost = document.getElementById( HOST_ID ); - if ( oldHost ) oldHost.remove(); - const host = document.createElement( 'div' ); - host.id = HOST_ID; - host.style.cssText = - 'all: initial; position: absolute; top: 0; left: 0; width: 0; height: 0; pointer-events: none; z-index: 2147483647;'; - document.body.appendChild( host ); - const root = host.attachShadow( { mode: 'open' } ); - - window.__studioInspectorDispose = () => { - teardown.abort(); - host.remove(); - delete window.__studioInspectorMounted; - delete window.__studioInspectorDispose; - }; - - const style = document.createElement( 'style' ); - style.textContent = ` + - '`' + - String.raw` - :host { all: initial; } - * { box-sizing: border-box; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } - .highlight { - position: absolute; pointer-events: none; - border: 2px solid #2563eb; - background: rgba(37,99,235,0.1); - border-radius: 2px; - } - .marker { - position: absolute; pointer-events: auto; cursor: pointer; - width: 22px; height: 22px; - background: #2563eb; color: #fff; - border: 2px solid #fff; - border-radius: 50%; - box-shadow: 0 2px 6px rgba(0,0,0,0.3); - font: 700 11px/1 inherit; - display: inline-flex; align-items: center; justify-content: center; - transform: translate(-50%, -50%); - } - .popup { - position: fixed; width: 320px; - background: #1a1a1a; color: #fff; - border-radius: 12px; - box-shadow: 0 4px 24px rgba(0,0,0,0.3), 0 0 0 1px rgba(255,255,255,0.08); - padding: 12px; - pointer-events: auto; - display: flex; flex-direction: column; gap: 8px; - } - .popup .target { - font-size: 11px; color: rgba(255,255,255,0.5); - overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - } - .popup textarea { - width: 100%; min-height: 72px; resize: vertical; - background: rgba(255,255,255,0.05); color: #fff; - border: 1px solid rgba(255,255,255,0.15); border-radius: 8px; - padding: 8px; font: 13px/1.4 inherit; outline: none; - } - .popup textarea:focus { border-color: #2563eb; } - .popup .actions { display: flex; justify-content: flex-end; gap: 4px; } - /* Sized so Delete/Cancel/Update/Send to chat all fit one row of the - 320px popup; nowrap keeps a tight fit from wrapping a label onto a - second line instead of the row overflowing visibly. */ - .popup button { - padding: 6px 8px; border-radius: 16px; border: none; - font: 600 11px/1 inherit; white-space: nowrap; cursor: pointer; - } - .popup .delete { background: transparent; color: rgba(255,255,255,0.5); margin-right: auto; } - .popup .delete:hover { color: #ef4444; } - .popup .cancel { background: transparent; color: rgba(255,255,255,0.7); } - .popup .cancel:hover { background: rgba(255,255,255,0.08); } - .popup .save { background: #fff; color: #1a1a1a; } - .popup .save[disabled] { opacity: 0.4; cursor: default; } - .popup .submit { background: rgba(255,255,255,0.12); color: #fff; } - .popup .submit[disabled] { opacity: 0.4; cursor: default; } - ` + - '`' + - String.raw`; - root.appendChild( style ); - - /* ------------------------------------------------------------------ - * State + DOM - * ---------------------------------------------------------------- */ - let isPicking = false; - let hoveredEl = null; - let activePopup = null; /* { id?, target, comment, fromPicker? } */ - let annotations = Array.isArray( window.__studioInspectorState ) - ? window.__studioInspectorState.slice() - : []; - - const markerNodes = new Map(); /* id -> marker element */ - let highlightNode = null; - let popupNode = null; - - function persistAnnotations() { - window.__studioInspectorState = annotations; - send( { type: 'annotations-updated', annotations: annotations.slice() } ); - } - - function sendState() { - send( { - type: 'state', - isPicking, - annotationCount: annotations.length, - } ); - } - - function syncMarkers() { - const currentPath = window.location.pathname + window.location.search; - const ids = new Set( annotations.map( ( a ) => a.id ) ); - for ( const [ id, marker ] of markerNodes ) { - if ( ! ids.has( id ) ) { - marker.remove(); - markerNodes.delete( id ); - } - } - annotations.forEach( ( ann, idx ) => { - /* Only render markers for annotations made on the current page. - * Annotations from other pages are preserved for submission but - * their document-coordinate positions would be meaningless here. */ - const onCurrentPage = ! ann.path || ann.path === currentPath; - let marker = markerNodes.get( ann.id ); - if ( ! onCurrentPage ) { - if ( marker ) { - marker.remove(); - markerNodes.delete( ann.id ); - } - return; - } - if ( ! marker ) { - marker = document.createElement( 'div' ); - marker.className = 'marker'; - marker.addEventListener( 'click', ( e ) => { - e.stopPropagation(); - const current = annotations.find( ( a ) => a.id === ann.id ); - if ( current ) openPopupForAnnotation( current ); - } ); - /* Use the document-coord rect captured at save time so the - * marker's position is fixed in document space and scrolls - * with the page. No per-scroll repositioning needed. */ - const box = ann.documentRect || ann.boundingBox || { left: 0, top: 0, width: 0, height: 0 }; - marker.style.left = ( box.left + box.width ) + 'px'; - marker.style.top = box.top + 'px'; - root.appendChild( marker ); - markerNodes.set( ann.id, marker ); - } - marker.textContent = String( idx + 1 ); - marker.title = ann.comment; - } ); - } - - function showHighlight( el ) { - if ( highlightNode ) { - highlightNode.remove(); - highlightNode = null; - } - if ( ! el || ! isPicking ) return; - const r = documentRect( el ); - highlightNode = document.createElement( 'div' ); - highlightNode.className = 'highlight'; - highlightNode.style.left = r.left + 'px'; - highlightNode.style.top = r.top + 'px'; - highlightNode.style.width = r.width + 'px'; - highlightNode.style.height = r.height + 'px'; - root.appendChild( highlightNode ); - } - - function showPopup() { - if ( popupNode ) { - popupNode.remove(); - popupNode = null; - } - if ( activePopup ) { - popupNode = buildPopup( activePopup ); - root.appendChild( popupNode ); - } - } - - function render() { - syncMarkers(); - showHighlight( hoveredEl ); - showPopup(); - sendState(); - } - - function togglePicking() { - isPicking = ! isPicking; - if ( ! isPicking ) hoveredEl = null; - activePopup = null; - persistAnnotations(); - render(); - } - - function commitActivePopup() { - if ( ! activePopup ) return true; - const state = activePopup; - const trimmed = ( state.comment || '' ).trim(); - if ( ! trimmed ) return false; - if ( state.id ) { - annotations = annotations.map( ( annotation ) => - annotation.id === state.id - ? Object.assign( {}, annotation, { comment: trimmed, updatedAt: Date.now() } ) - : annotation - ); - } else { - annotations = annotations.concat( [ - { - id: uid(), - comment: trimmed, - selector: state.target.selector, - tag: state.target.tag, - nearbyText: state.target.nearbyText, - boundingBox: state.target.boundingBox, - documentRect: state.target.documentRect, - computedStyles: state.target.computedStyles, - path: window.location.pathname + window.location.search, - url: window.location.href, - timestamp: Date.now(), - }, - ] ); - } - persistAnnotations(); - return true; - } - - function hasDraft() { - return !! ( activePopup && ( activePopup.comment || '' ).trim() ); - } - - function submitAnnotations() { - if ( annotations.length === 0 && ! hasDraft() ) { - sendState(); - return; - } - /* An untouched popup is a draft the user never filled in — drop it - * rather than blocking the notes they did save. */ - if ( hasDraft() ) { - commitActivePopup(); - } else { - activePopup = null; - } - send( { type: 'done', annotations: annotations.slice() } ); - annotations = []; - activePopup = null; - isPicking = false; - hoveredEl = null; - persistAnnotations(); - render(); - } - - window.addEventListener( - COMMAND_EVENT, - ( event ) => { - const command = event.detail || {}; - if ( command.type === 'toggle-picking' ) { - togglePicking(); - return; - } - if ( command.type === 'submit' ) { - submitAnnotations(); - return; - } - if ( command.type === 'report-state' ) { - sendState(); - } - }, - { signal: teardown.signal } - ); - - function buildPopup( state ) { - const popup = document.createElement( 'div' ); - popup.className = 'popup'; - - /* Position the popup near the element using viewport coords (it's - * \`position: fixed\` so it stays in the viewport). Falls back to - * centre if the element can't be located. */ - let el = null; - try { - el = state.target.selector ? document.querySelector( state.target.selector ) : null; - } catch {} - if ( el ) { - const r = el.getBoundingClientRect(); - const popupWidth = 320; - const gap = 12; - const left = Math.min( - Math.max( 8, r.left + r.width / 2 - popupWidth / 2 ), - window.innerWidth - popupWidth - 8 - ); - let top = r.bottom + gap; - if ( top + 200 > window.innerHeight ) { - top = Math.max( 8, r.top - 200 - gap ); - } - popup.style.left = left + 'px'; - popup.style.top = top + 'px'; - } else { - popup.style.left = '50%'; - popup.style.top = '50%'; - popup.style.transform = 'translate(-50%, -50%)'; - } - - const target = document.createElement( 'div' ); - target.className = 'target'; - target.textContent = - state.target.tag + - ( state.target.nearbyText ? ' — ' + state.target.nearbyText : '' ); - popup.appendChild( target ); - - state.comment = state.comment || ''; - const ta = document.createElement( 'textarea' ); - ta.placeholder = 'What should change about this element?'; - ta.value = state.comment; - popup.appendChild( ta ); - setTimeout( () => ta.focus(), 0 ); - - const actions = document.createElement( 'div' ); - actions.className = 'actions'; - - if ( state.id ) { - const del = document.createElement( 'button' ); - del.className = 'delete'; - del.textContent = 'Delete'; - del.addEventListener( 'click', () => { - annotations = annotations.filter( ( a ) => a.id !== state.id ); - activePopup = null; - persistAnnotations(); - render(); - } ); - actions.appendChild( del ); - } - - const closePopup = () => { - activePopup = null; - hoveredEl = null; - persistAnnotations(); - render(); - }; - - const cancel = document.createElement( 'button' ); - cancel.className = 'cancel'; - cancel.textContent = 'Cancel'; - cancel.addEventListener( 'click', closePopup ); - actions.appendChild( cancel ); - - const save = document.createElement( 'button' ); - save.className = 'save'; - save.textContent = state.id ? 'Update' : 'Save'; - save.addEventListener( 'click', () => { - if ( ! commitActivePopup() ) return; - closePopup(); - } ); - actions.appendChild( save ); - - const submit = document.createElement( 'button' ); - submit.className = 'submit'; - submit.textContent = 'Send to chat'; - submit.addEventListener( 'click', submitAnnotations ); - actions.appendChild( submit ); - - function syncActions() { - save.disabled = ! state.comment.trim(); - /* Sending stays available while notes are already saved, even if - * this popup is an untouched draft — submit discards it. */ - submit.disabled = save.disabled && annotations.length === 0; - } - syncActions(); - - ta.addEventListener( 'input', () => { - state.comment = ta.value; - syncActions(); - } ); - ta.addEventListener( 'keydown', ( event ) => { - if ( event.key !== 'Enter' || event.isComposing || event.keyCode === 229 ) return; - if ( event.metaKey || event.ctrlKey ) { - event.preventDefault(); - const start = ta.selectionStart; - const end = ta.selectionEnd; - ta.value = ta.value.slice( 0, start ) + '\n' + ta.value.slice( end ); - state.comment = ta.value; - ta.setSelectionRange( start + 1, start + 1 ); - syncActions(); - return; - } - if ( event.shiftKey ) return; - event.preventDefault(); - save.click(); - } ); - - popup.appendChild( actions ); - - popup.addEventListener( 'click', ( e ) => e.stopPropagation() ); - popup.addEventListener( 'mousemove', ( e ) => e.stopPropagation() ); - - return popup; - } - - /* Editing an existing note leaves picking mode alone: markers stay - * clickable when picking is off, and silently switching it on would - * swallow every subsequent link click in the page. */ - function openPopupForAnnotation( ann ) { - hoveredEl = null; - activePopup = { - id: ann.id, - comment: ann.comment, - target: { - selector: ann.selector, - tag: ann.tag, - nearbyText: ann.nearbyText, - boundingBox: ann.boundingBox, - documentRect: ann.documentRect, - computedStyles: ann.computedStyles, - }, - }; - persistAnnotations(); - render(); - } - - function openPopupForElement( el ) { - const viewport = el.getBoundingClientRect(); - activePopup = { - fromPicker: true, - comment: '', - target: { - selector: buildSelector( el ), - tag: el.tagName.toLowerCase(), - nearbyText: nearbyText( el ), - boundingBox: { x: viewport.x, y: viewport.y, width: viewport.width, height: viewport.height }, - documentRect: documentRect( el ), - computedStyles: pickComputedStyles( el ), - }, - }; - persistAnnotations(); - render(); - } - - function isOurElement( el ) { - return !! ( el && el.closest && el.closest( '#' + HOST_ID ) ); - } - - /* ------------------------------------------------------------------ - * Picking interactions. Only the highlight is updated on mousemove — - * markers are document-anchored and don't move with mouse position. - * No scroll/resize listeners: markers and highlight live in document - * coordinates and follow the page naturally. - * ---------------------------------------------------------------- */ - document.addEventListener( - 'mousemove', - ( e ) => { - if ( ! isPicking || activePopup ) return; - if ( isOurElement( e.target ) ) { - if ( hoveredEl !== null ) { - hoveredEl = null; - showHighlight( null ); - } - return; - } - if ( hoveredEl !== e.target ) { - hoveredEl = e.target; - showHighlight( hoveredEl ); - } - }, - { capture: true, signal: teardown.signal } - ); - - document.addEventListener( - 'click', - ( e ) => { - if ( ! isPicking || activePopup ) return; - if ( isOurElement( e.target ) ) return; - e.preventDefault(); - e.stopPropagation(); - openPopupForElement( e.target ); - }, - { capture: true, signal: teardown.signal } - ); - - document.addEventListener( - 'keydown', - ( e ) => { - const browserCommand = getBrowserShortcutCommand( e ); - if ( browserCommand ) { - e.preventDefault(); - e.stopPropagation(); - send( { type: 'browser-command', command: browserCommand } ); - return; - } - if ( e.key !== 'Escape' ) return; - if ( activePopup ) { - activePopup = null; - persistAnnotations(); - render(); - } else if ( isPicking ) { - isPicking = false; - hoveredEl = null; - persistAnnotations(); - render(); - } - }, - { capture: true, signal: teardown.signal } - ); - - render(); -} )(); -`; +export { + INSPECTOR_BRIDGE_PREFIX, + INSPECTOR_COMMAND_EVENT, + createStudioInspectorPageScript as createInspectorPageScript, +} from '@studio/common/ai/inspector-page-script'; diff --git a/apps/ui/src/lib/icons.tsx b/apps/ui/src/lib/icons.tsx index 4460fc5345..bfa4072922 100644 --- a/apps/ui/src/lib/icons.tsx +++ b/apps/ui/src/lib/icons.tsx @@ -49,3 +49,10 @@ export const databaseIcon = ( /> ); + +export const annotationIcon = ( + + + + +); diff --git a/packages/common/ai/inspector-page-script.ts b/packages/common/ai/inspector-page-script.ts new file mode 100644 index 0000000000..36e789af99 --- /dev/null +++ b/packages/common/ai/inspector-page-script.ts @@ -0,0 +1,1244 @@ +/** + * Shared annotation inspector injected into Studio's preview webview and the + * standalone CLI annotation browser. + * + * Runs in the cross-origin guest page so it uses vanilla DOM in a Shadow DOM + * root — React isn't loaded there. Communicates with the host renderer via a + * structured `console.log` line that the host receives through the webview's + * `console-message` event: + * guest -> host: `__studio-inspector__:{ "type": "done", ... }` + * + * The bridge also reports inspector state and forwards browser shortcuts. + * Toolbar commands travel in the other direction through a custom event. + */ + +export const INSPECTOR_BRIDGE_PREFIX = '__studio-inspector__:'; +export const INSPECTOR_COMMAND_EVENT = '__studio-inspector-command'; +const INSPECTOR_BRIDGE_TOKEN_PLACEHOLDER = '__STUDIO_INSPECTOR_BRIDGE_TOKEN__'; +const INSPECTOR_EMBEDDED_TOOLBAR_PLACEHOLDER = '__STUDIO_INSPECTOR_EMBEDDED_TOOLBAR__'; + +export function createStudioInspectorPageScript( bridgeToken: string ): string { + if ( ! /^[a-zA-Z0-9_-]{16,128}$/.test( bridgeToken ) ) { + throw new Error( 'Invalid inspector bridge token.' ); + } + return INSPECTOR_PAGE_SCRIPT.replace( INSPECTOR_BRIDGE_TOKEN_PLACEHOLDER, bridgeToken ).replace( + INSPECTOR_EMBEDDED_TOOLBAR_PLACEHOLDER, + 'false' + ); +} + +export function createCliInspectorPageScript(): string { + return INSPECTOR_PAGE_SCRIPT.replace( INSPECTOR_BRIDGE_TOKEN_PLACEHOLDER, '' ).replace( + INSPECTOR_EMBEDDED_TOOLBAR_PLACEHOLDER, + 'true' + ); +} + +export const INSPECTOR_PAGE_SCRIPT = + String.raw` +( () => { + const BRIDGE_TOKEN = '` + + INSPECTOR_BRIDGE_TOKEN_PLACEHOLDER + + String.raw`'; + const EMBEDDED_TOOLBAR = ` + + INSPECTOR_EMBEDDED_TOOLBAR_PLACEHOLDER + + String.raw`; + const HOST_ID = '__studio-inspector-host'; + if ( window.__studioInspectorMounted && document.getElementById( HOST_ID ) ) { + if ( ! EMBEDDED_TOOLBAR ) window.dispatchEvent( + new CustomEvent( '` + + INSPECTOR_COMMAND_EVENT + + String.raw`', { detail: { type: 'report-state', bridgeToken: BRIDGE_TOKEN } } ) + ); + return; + } + if ( typeof window.__studioInspectorDispose === 'function' ) { + window.__studioInspectorDispose(); + } + window.__studioInspectorMounted = true; + const teardown = new AbortController(); + + const BRIDGE_PREFIX = '` + + INSPECTOR_BRIDGE_PREFIX + + String.raw`'; + const COMMAND_EVENT = '` + + INSPECTOR_COMMAND_EVENT + + String.raw`'; + const STORAGE_KEY = 'studio-inspector-annotations-v1'; + const MAX_ANNOTATIONS = 100; + + function send( payload ) { + if ( EMBEDDED_TOOLBAR ) return; + try { + console.log( + BRIDGE_PREFIX + JSON.stringify( Object.assign( { bridgeToken: BRIDGE_TOKEN }, payload ) ) + ); + } catch ( err ) { + /* JSON.stringify can fail on cycles; the host treats missing + * messages as no-ops, so we swallow rather than crash the page. */ + } + } + + function isApplePlatform() { + return /mac|iphone|ipad|ipod/i.test( navigator.platform || navigator.userAgent || '' ); + } + + function isTextEntryTarget( el ) { + if ( ! el || el.nodeType !== 1 ) return false; + if ( el.isContentEditable ) return true; + const tag = el.tagName.toLowerCase(); + return tag === 'input' || tag === 'textarea' || tag === 'select'; + } + + function getBrowserShortcutCommand( event ) { + if ( event.defaultPrevented || event.repeat ) return null; + const apple = isApplePlatform(); + if ( event.key === 'ArrowLeft' || event.key === 'ArrowRight' ) { + /* Layout-independent back/forward aliases: the bracket chords need + * Option/AltGr on many European layouts. Skipped while editing text + * to keep native caret movement. */ + const hasNavModifier = apple + ? event.metaKey && ! event.ctrlKey && ! event.altKey + : event.altKey && ! event.ctrlKey && ! event.metaKey; + if ( ! hasNavModifier || event.shiftKey || isTextEntryTarget( event.target ) ) return null; + return event.key === 'ArrowLeft' ? 'back' : 'forward'; + } + if ( event.altKey ) return null; + const hasPrimaryModifier = apple ? event.metaKey : event.ctrlKey; + if ( ! hasPrimaryModifier ) return null; + const key = event.key.toLowerCase(); + /* The host owns full preview, but in that mode this page covers most of + * the window — so the chord is caught here and forwarded back. */ + if ( event.shiftKey ) return key === 'f' ? 'full-preview' : null; + if ( key === 'r' ) return 'reload'; + if ( key === '[' ) return 'back'; + if ( key === ']' ) return 'forward'; + return null; + } + + function buildSelector( el ) { + if ( ! el || el.nodeType !== 1 ) return ''; + if ( el.id ) return '#' + CSS.escape( el.id ); + const parts = []; + let node = el; + while ( node && node.nodeType === 1 && node !== document.documentElement ) { + let part = node.tagName.toLowerCase(); + if ( node.classList && node.classList.length ) { + const classes = Array.from( node.classList ) + .filter( ( c ) => ! c.startsWith( '__studio-' ) ) + .slice( 0, 3 ) + .map( ( c ) => '.' + CSS.escape( c ) ) + .join( '' ); + part += classes; + } + const parent = node.parentElement; + if ( parent ) { + const sameTagSiblings = Array.from( parent.children ).filter( + ( c ) => c.tagName === node.tagName && c.id !== HOST_ID + ); + if ( sameTagSiblings.length > 1 ) { + part += ':nth-of-type(' + ( sameTagSiblings.indexOf( node ) + 1 ) + ')'; + } + } + parts.unshift( part ); + node = parent; + if ( parts.length >= 6 ) break; + } + return parts.join( ' > ' ); + } + + function nearbyText( el ) { + const text = ( el.innerText || el.textContent || '' ) + .replace( /\s+/g, ' ' ) + .trim(); + return text.length > 200 ? text.slice( 0, 200 ) + '…' : text; + } + + function pickComputedStyles( el ) { + const cs = window.getComputedStyle( el ); + const keys = [ + 'color', 'background-color', 'font-size', 'font-weight', + 'font-family', 'line-height', 'padding', 'margin', + 'border', 'display', 'width', 'height', + ]; + const out = {}; + for ( const k of keys ) { + out[ k ] = cs.getPropertyValue( k ); + } + return out; + } + + function uid() { + return 'a_' + Math.random().toString( 36 ).slice( 2, 10 ); + } + + function documentRect( el ) { + const r = el.getBoundingClientRect(); + return { + left: r.left + window.scrollX, + top: r.top + window.scrollY, + width: r.width, + height: r.height, + }; + } + + const oldHost = document.getElementById( HOST_ID ); + if ( oldHost ) oldHost.remove(); + const host = document.createElement( 'div' ); + host.id = HOST_ID; + host.style.cssText = + 'all: initial; position: absolute; top: 0; left: 0; width: 0; height: 0; pointer-events: none; z-index: 2147483647;'; + document.body.appendChild( host ); + const root = host.attachShadow( { mode: 'open' } ); + + const style = document.createElement( 'style' ); + style.textContent = ` + + '`' + + String.raw` + :host { all: initial; } + * { box-sizing: border-box; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } + .highlight, .annotation-highlight { + position: absolute; pointer-events: none; + border: 2px solid #2563eb; + background: rgba(37,99,235,0.1); + border-radius: 4px; + box-shadow: 0 0 0 1px rgba(0,0,0,0.9); + } + .scrim { + position: fixed; pointer-events: none; + background: rgba(0,0,0,0.52); + z-index: 10; + } + .highlight { z-index: 11; } + .annotation-highlight { + background: rgba(37,99,235,0.07); + } + .marker { + position: absolute; pointer-events: auto; cursor: pointer; + z-index: 2; + width: 22px; height: 22px; + padding: 0; appearance: none; + background: #2563eb; color: #fff; + border: 2px solid #fff; + border-radius: 50%; + box-shadow: 0 2px 6px rgba(0,0,0,0.3); + font: 700 11px/1 inherit; + display: inline-flex; align-items: center; justify-content: center; + transform: translate(-50%, -50%); + } + .toolbar { + position: fixed; right: 16px; bottom: 16px; z-index: 13; + display: flex; align-items: center; gap: 6px; + padding: 6px; + background: rgba(250,250,250,0.94); color: #1e1e1e; + border: 1px solid rgba(30,30,30,0.18); border-radius: 10px 10px 18px 10px; + box-shadow: 0 8px 28px rgba(0,0,0,0.22); + backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); + pointer-events: auto; + } + .toolbar button { + height: 30px; padding: 0 10px; + border: 0; border-radius: 6px; + background: transparent; color: inherit; + font: 600 12px/1 inherit; cursor: pointer; + } + .toolbar button:hover { background: rgba(30,30,30,0.08); } + .toolbar .submit { background: #2563eb; color: #fff; } + .toolbar .submit:hover:not([disabled]) { filter: brightness(0.92); } + .toolbar .submit[disabled] { opacity: 0.4; cursor: default; } + .toolbar .count { + min-width: 22px; height: 22px; padding: 0 6px; + display: inline-flex; align-items: center; justify-content: center; + border-radius: 11px; background: rgba(30,30,30,0.08); + font: 600 11px/1 inherit; + } + .toast { + position: fixed; right: 16px; bottom: 72px; z-index: 13; + padding: 8px 12px; border-radius: 8px; + background: #1e1e1e; color: #fff; + box-shadow: 0 4px 16px rgba(0,0,0,0.25); + font: 500 12px/1.3 inherit; pointer-events: none; + } + .popup { + --popup-fill: rgba(250,250,250,0.92); + --popup-tint: rgba(255,255,255,0.04); + --popup-text: #1e1e1e; + --popup-text-weak: rgba(30,30,30,0.58); + position: fixed; width: min(360px, calc(100vw - 16px)); z-index: 12; + background-color: var(--popup-fill); + background-image: linear-gradient(var(--popup-tint), var(--popup-tint)); + backdrop-filter: blur(20px) saturate(115%); + -webkit-backdrop-filter: blur(20px) saturate(115%); + color: var(--popup-text); + border: 2px solid #2563eb; + border-radius: 8px 8px 20px 8px; + box-shadow: 0 8px 32px rgba(0,0,0,0.2), 0 0 0 1px rgba(0,0,0,0.9); + padding: 8px 8px 6px; + pointer-events: auto; + display: flex; flex-direction: column; gap: 2px; + will-change: transform; + } + .popup.dragging { + backdrop-filter: none; + -webkit-backdrop-filter: none; + will-change: transform; + } + .popup .target-row { + display: flex; align-items: center; justify-content: space-between; + gap: 6px; min-width: 0; + cursor: grab; user-select: none; + } + .popup .target-row.dragging { cursor: grabbing; } + .popup .target { + min-width: 0; max-width: 70%; + padding: 3px 7px; + border: 1px solid rgba(30,30,30,0.18); + border-radius: 6px; + background: rgba(255,255,255,0.38); + font: 500 11px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; + color: var(--popup-text); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + .popup .layer-controls { + display: inline-flex; align-items: center; flex: none; + padding: 1px; + border: 1px solid rgba(30,30,30,0.14); + border-radius: 7px; + background: rgba(255,255,255,0.28); + } + .popup .layer-button { + width: 18px; height: 18px; padding: 0; + border-radius: 5px; + background: transparent; color: var(--popup-text-weak); + font-size: 15px; line-height: 1; + } + .popup .layer-button:hover { background: rgba(30,30,30,0.08); color: var(--popup-text); } + .popup .layer-count { + min-width: 24px; color: var(--popup-text-weak); + font-size: 10px; text-align: center; + } + .popup textarea { + width: 100%; height: 24px; min-height: 24px; max-height: 100px; resize: none; + background: transparent; color: var(--popup-text); + border: 0; border-radius: 0; + margin-top: 4px; padding: 3px 4px 0; font: 14px/1.3 inherit; outline: none; + } + .popup textarea::placeholder { color: var(--popup-text-weak); } + .popup .actions { + display: flex; align-items: center; justify-content: flex-end; gap: 4px; + margin-top: -2px; + } + .popup .left-actions { display: inline-flex; align-items: center; margin-right: auto; } + .popup button { + padding: 6px 10px; border-radius: 8px; border: none; + font: 600 12px/1 inherit; cursor: pointer; + } + .popup .delete { background: transparent; color: var(--popup-text-weak); } + .popup .delete:hover { color: #ef4444; } + .popup .cancel { + display: inline-flex; align-items: center; height: 28px; + padding: 0 4px; border-radius: 4px; + background: transparent; color: var(--popup-text); + font-size: 11px; font-weight: 500; line-height: 1; opacity: 0.72; + } + .popup .cancel:hover { background: rgba(30,30,30,0.08); color: var(--popup-text); } + .popup .send-to-chat { + background: rgba(30,30,30,0.08); color: var(--popup-text); + border-radius: 4px; + font-size: 11px; font-weight: 600; + } + .popup .send-to-chat:hover:not([disabled]) { background: rgba(30,30,30,0.13); } + .popup .send-to-chat[disabled] { opacity: 0.4; cursor: default; } + .popup .save { + display: inline-flex; align-items: center; justify-content: center; + position: relative; + width: 28px; height: 28px; padding: 0; + border-radius: 50%; background: #2563eb; color: #fff; + font-size: 0; + } + .popup .save::before, .popup .save::after { + content: ''; position: absolute; left: 50%; top: 50%; + width: 12px; height: 2px; border-radius: 1px; + background: currentColor; transform: translate(-50%, -50%); + } + .popup .save::after { width: 2px; height: 12px; } + .popup .save:hover:not([disabled]) { filter: brightness(0.92); } + .popup .save[disabled] { opacity: 0.4; cursor: default; } + @media (prefers-color-scheme: dark) { + .highlight, .annotation-highlight { + box-shadow: 0 0 0 1px rgba(255,255,255,0.9); + } + .popup { + --popup-fill: rgba(20,20,20,0.92); + --popup-tint: rgba(0,0,0,0.04); + --popup-text: #f4f4f4; + --popup-text-weak: rgba(244,244,244,0.58); + box-shadow: 0 8px 32px rgba(0,0,0,0.42), 0 0 0 1px rgba(255,255,255,0.9); + } + .toolbar { + background: rgba(20,20,20,0.94); color: #f4f4f4; + border-color: rgba(255,255,255,0.2); + } + .toolbar button:hover, .toolbar .count { background: rgba(255,255,255,0.1); } + .popup .target { + border-color: rgba(255,255,255,0.2); + background: rgba(0,0,0,0.2); + } + .popup .layer-controls { + border-color: rgba(255,255,255,0.16); + background: rgba(0,0,0,0.16); + } + .popup .cancel:hover { background: rgba(255,255,255,0.08); } + .popup .send-to-chat { background: rgba(255,255,255,0.1); } + .popup .send-to-chat:hover:not([disabled]) { background: rgba(255,255,255,0.16); } + .popup .layer-button:hover { background: rgba(255,255,255,0.08); } + } + ` + + '`' + + String.raw`; + root.appendChild( style ); + + /* ------------------------------------------------------------------ + * State + DOM + * ---------------------------------------------------------------- */ + let isPicking = false; + let hoveredEl = null; + let activePopup = null; /* { id?, target, comment } */ + function loadAnnotations() { + let stored = window.__studioInspectorState; + if ( EMBEDDED_TOOLBAR ) { + try { + stored = JSON.parse( localStorage.getItem( STORAGE_KEY ) || '[]' ); + } catch { + stored = []; + } + } + return Array.isArray( stored ) + ? stored + .filter( + ( annotation ) => + typeof annotation === 'object' && + annotation !== null && + typeof annotation.id === 'string' && + typeof annotation.comment === 'string' && + annotation.comment.trim() + ) + .slice( 0, MAX_ANNOTATIONS ) + : []; + } + + let annotations = loadAnnotations(); + + const markerNodes = new Map(); /* id -> marker element */ + const annotationHighlightNodes = new Map(); /* id -> highlight element */ + const scrimNodes = []; + let highlightNode = null; + let popupNode = null; + let scrollLock = null; + + window.__studioInspectorDispose = () => { + teardown.abort(); + if ( scrollLock ) { + document.documentElement.style.overflow = scrollLock.documentOverflow; + document.body.style.overflow = scrollLock.bodyOverflow; + scrollLock = null; + } + host.remove(); + delete window.__studioInspectorMounted; + delete window.__studioInspectorDispose; + }; + + function syncScrollLock() { + if ( activePopup && ! scrollLock ) { + scrollLock = { + documentOverflow: document.documentElement.style.overflow, + bodyOverflow: document.body.style.overflow, + }; + document.documentElement.style.overflow = 'hidden'; + document.body.style.overflow = 'hidden'; + } else if ( ! activePopup && scrollLock ) { + document.documentElement.style.overflow = scrollLock.documentOverflow; + document.body.style.overflow = scrollLock.bodyOverflow; + scrollLock = null; + } + } + + function persistAnnotations() { + let persistedAnnotations = []; + try { + persistedAnnotations = JSON.parse( JSON.stringify( annotations ) ); + } catch { + persistedAnnotations = []; + } + window.__studioInspectorState = persistedAnnotations; + if ( EMBEDDED_TOOLBAR ) { + try { + localStorage.setItem( STORAGE_KEY, JSON.stringify( persistedAnnotations ) ); + } catch {} + } + send( { type: 'annotations-updated', annotations: persistedAnnotations } ); + } + + function sendState() { + send( { + type: 'state', + isPicking, + annotationCount: annotations.length, + } ); + } + + function syncMarkers() { + const visibleAnnotations = annotations.filter( isAnnotationOnCurrentPage ); + const ids = new Set( visibleAnnotations.map( ( annotation ) => annotation.id ) ); + for ( const [ id, marker ] of markerNodes ) { + if ( ! ids.has( id ) ) { + marker.remove(); + markerNodes.delete( id ); + } + } + visibleAnnotations.forEach( ( ann ) => { + let marker = markerNodes.get( ann.id ); + if ( ! marker ) { + marker = document.createElement( 'button' ); + marker.type = 'button'; + marker.className = 'marker'; + marker.addEventListener( 'click', ( e ) => { + e.stopPropagation(); + const current = annotations.find( ( a ) => a.id === ann.id ); + if ( current ) openPopupForAnnotation( current ); + } ); + root.appendChild( marker ); + markerNodes.set( ann.id, marker ); + } + const box = resolveTargetRect( ann ); + if ( box ) { + const inset = 12; + const viewport = { + left: window.scrollX, + top: window.scrollY, + right: window.scrollX + window.innerWidth, + bottom: window.scrollY + window.innerHeight, + }; + const boxRight = box.left + box.width; + const boxBottom = box.top + box.height; + const isVisible = + boxRight >= viewport.left && + box.left <= viewport.right && + boxBottom >= viewport.top && + box.top <= viewport.bottom; + marker.style.display = isVisible ? '' : 'none'; + if ( isVisible ) { + marker.style.left = + Math.min( + Math.max( boxRight, viewport.left + inset ), + viewport.right - inset + ) + 'px'; + marker.style.top = + Math.min( + Math.max( box.top, viewport.top + inset ), + viewport.bottom - inset + ) + 'px'; + } + } + const annotationNumber = annotations.findIndex( ( item ) => item.id === ann.id ) + 1; + marker.textContent = String( annotationNumber ); + marker.title = ann.comment; + marker.setAttribute( 'aria-label', 'Annotation ' + annotationNumber + ': ' + ann.comment ); + } ); + } + + function isAnnotationOnCurrentPage( annotation ) { + return ! annotation.pathname || annotation.pathname === window.location.pathname; + } + + function resolveTargetRect( target ) { + let el = null; + try { + el = target.selector ? document.querySelector( target.selector ) : null; + } catch {} + return el ? documentRect( el ) : target.documentRect || target.boundingBox || null; + } + + function positionHighlight( node, rect ) { + node.style.left = rect.left + 'px'; + node.style.top = rect.top + 'px'; + node.style.width = rect.width + 'px'; + node.style.height = rect.height + 'px'; + } + + function syncAnnotationHighlights() { + const visibleAnnotations = annotations.filter( isAnnotationOnCurrentPage ); + const ids = new Set( visibleAnnotations.map( ( annotation ) => annotation.id ) ); + for ( const [ id, node ] of annotationHighlightNodes ) { + if ( ! ids.has( id ) ) { + node.remove(); + annotationHighlightNodes.delete( id ); + } + } + for ( const annotation of visibleAnnotations ) { + const rect = resolveTargetRect( annotation ); + if ( ! rect ) continue; + let node = annotationHighlightNodes.get( annotation.id ); + if ( ! node ) { + node = document.createElement( 'div' ); + node.className = 'annotation-highlight'; + root.appendChild( node ); + annotationHighlightNodes.set( annotation.id, node ); + } + positionHighlight( node, rect ); + } + } + + function showHighlight( el ) { + if ( highlightNode ) { + highlightNode.remove(); + highlightNode = null; + } + const rect = activePopup ? resolveTargetRect( activePopup.target ) : el ? documentRect( el ) : null; + if ( ! rect || ! isPicking ) return; + highlightNode = document.createElement( 'div' ); + highlightNode.className = 'highlight'; + positionHighlight( highlightNode, rect ); + root.appendChild( highlightNode ); + } + + function syncScrim() { + if ( ! activePopup ) { + scrimNodes.splice( 0 ).forEach( ( node ) => node.remove() ); + return; + } + const rect = resolveTargetRect( activePopup.target ); + if ( ! rect ) { + scrimNodes.splice( 0 ).forEach( ( node ) => node.remove() ); + return; + } + while ( scrimNodes.length < 4 ) { + const node = document.createElement( 'div' ); + node.className = 'scrim'; + root.appendChild( node ); + scrimNodes.push( node ); + } + const left = Math.min( window.innerWidth, Math.max( 0, rect.left - window.scrollX ) ); + const top = Math.min( window.innerHeight, Math.max( 0, rect.top - window.scrollY ) ); + const right = Math.min( + window.innerWidth, + Math.max( left, rect.left + rect.width - window.scrollX ) + ); + const bottom = Math.min( + window.innerHeight, + Math.max( top, rect.top + rect.height - window.scrollY ) + ); + const panels = [ + { left: 0, top: 0, width: window.innerWidth, height: top }, + { left: 0, top: bottom, width: window.innerWidth, height: window.innerHeight - bottom }, + { left: 0, top, width: left, height: bottom - top }, + { left: right, top, width: window.innerWidth - right, height: bottom - top }, + ]; + scrimNodes.forEach( ( node, index ) => { + const panel = panels[ index ]; + node.style.left = panel.left + 'px'; + node.style.top = panel.top + 'px'; + node.style.width = panel.width + 'px'; + node.style.height = panel.height + 'px'; + } ); + } + + function showPopup() { + if ( popupNode ) { + popupNode.remove(); + popupNode = null; + } + if ( activePopup ) { + popupNode = buildPopup( activePopup ); + root.appendChild( popupNode ); + } + } + + function showEmbeddedToolbar() { + const existing = root.querySelector( '.toolbar' ); + if ( existing ) existing.remove(); + if ( ! EMBEDDED_TOOLBAR ) return; + const toolbar = document.createElement( 'div' ); + toolbar.className = 'toolbar'; + const modeButton = document.createElement( 'button' ); + modeButton.type = 'button'; + modeButton.textContent = isPicking ? 'Stop annotating' : 'Annotate'; + modeButton.addEventListener( 'click', togglePicking ); + toolbar.appendChild( modeButton ); + if ( annotations.length > 0 ) { + const count = document.createElement( 'span' ); + count.className = 'count'; + count.textContent = String( annotations.length ); + count.title = annotations.length + ' annotation(s)'; + toolbar.appendChild( count ); + } + const submit = document.createElement( 'button' ); + submit.type = 'button'; + submit.className = 'submit'; + submit.textContent = 'Send to agent'; + submit.disabled = annotations.length === 0 && ! ( activePopup && activePopup.comment.trim() ); + submit.addEventListener( 'click', submitAnnotations ); + toolbar.appendChild( submit ); + root.appendChild( toolbar ); + } + + function render() { + syncScrollLock(); + syncMarkers(); + syncAnnotationHighlights(); + syncScrim(); + showHighlight( hoveredEl ); + showPopup(); + showEmbeddedToolbar(); + sendState(); + } + + function togglePicking() { + isPicking = ! isPicking; + if ( ! isPicking ) hoveredEl = null; + activePopup = null; + persistAnnotations(); + render(); + } + + function commitActivePopup() { + if ( ! activePopup ) return true; + const state = activePopup; + const trimmed = ( state.comment || '' ).trim(); + if ( ! trimmed ) return false; + if ( state.id ) { + annotations = annotations.map( ( annotation ) => + annotation.id === state.id + ? Object.assign( {}, annotation, { comment: trimmed, updatedAt: Date.now() } ) + : annotation + ); + } else { + if ( annotations.length >= MAX_ANNOTATIONS ) return false; + state.id = uid(); + annotations = annotations.concat( [ + { + id: state.id, + comment: trimmed, + selector: state.target.selector, + tag: state.target.tag, + elementLabel: state.target.elementLabel, + nearbyText: state.target.nearbyText, + boundingBox: state.target.boundingBox, + documentRect: state.target.documentRect, + computedStyles: state.target.computedStyles, + pathname: window.location.pathname, + url: window.location.href, + timestamp: Date.now(), + }, + ] ); + } + persistAnnotations(); + return true; + } + + function submitAnnotations() { + if ( activePopup && ! ( activePopup.comment || '' ).trim() ) { + if ( annotations.length === 0 ) { + sendState(); + return; + } + activePopup = null; + } else if ( ! commitActivePopup() ) { + return; + } + if ( annotations.length === 0 ) { + sendState(); + return; + } + const sent = annotations.slice(); + if ( EMBEDDED_TOOLBAR ) { + window.__studioAnnotateDone = { + capturedAt: Date.now(), + url: window.location.href, + annotations: sent, + }; + } else { + send( { type: 'done', annotations: sent } ); + } + annotations = []; + activePopup = null; + isPicking = false; + hoveredEl = null; + persistAnnotations(); + render(); + if ( EMBEDDED_TOOLBAR ) showSubmissionToast( sent.length ); + } + + function showSubmissionToast( count ) { + const toast = document.createElement( 'div' ); + toast.className = 'toast'; + root.appendChild( toast ); + let seconds = 10; + const paint = () => { + toast.textContent = 'Sent ' + count + ' annotation(s) — closing in ' + seconds + 's'; + }; + paint(); + const interval = window.setInterval( () => { + seconds -= 1; + if ( seconds <= 0 ) { + window.clearInterval( interval ); + toast.textContent = 'Sent ' + count + ' annotation(s) — closing now…'; + } else paint(); + }, 1000 ); + } + + if ( ! EMBEDDED_TOOLBAR ) window.addEventListener( COMMAND_EVENT, ( event ) => { + const command = event.detail || {}; + if ( command.bridgeToken !== BRIDGE_TOKEN ) return; + if ( command.type === 'toggle-picking' ) { + togglePicking(); + return; + } + if ( command.type === 'submit' ) { + submitAnnotations(); + return; + } + if ( command.type === 'report-state' ) { + sendState(); + } + }, { signal: teardown.signal } ); + + function buildPopup( state ) { + const popup = document.createElement( 'div' ); + popup.className = 'popup'; + popup.setAttribute( 'role', 'dialog' ); + popup.setAttribute( 'aria-label', 'Annotate selected element' ); + + /* Position the popup near the element using viewport coords (it's + * \`position: fixed\` so it stays in the viewport). Falls back to + * centre if the element can't be located. */ + let el = null; + try { + el = state.target.selector ? document.querySelector( state.target.selector ) : null; + } catch {} + if ( state.popupPosition ) { + popup.style.left = state.popupPosition.left + 'px'; + popup.style.top = state.popupPosition.top + 'px'; + } else if ( el ) { + const r = el.getBoundingClientRect(); + const popupWidth = Math.min( 360, window.innerWidth - 16 ); + const gap = 12; + const left = Math.min( + Math.max( 8, r.left + r.width / 2 - popupWidth / 2 ), + window.innerWidth - popupWidth - 8 + ); + let top = r.bottom + gap; + if ( top + 150 > window.innerHeight ) { + top = Math.max( 8, r.top - 150 - gap ); + } + state.popupPosition = { left, top }; + popup.style.left = state.popupPosition.left + 'px'; + popup.style.top = state.popupPosition.top + 'px'; + } else { + state.popupPosition = { + left: Math.max( 8, ( window.innerWidth - Math.min( 360, window.innerWidth - 16 ) ) / 2 ), + top: Math.max( 8, ( window.innerHeight - 150 ) / 2 ), + }; + popup.style.left = state.popupPosition.left + 'px'; + popup.style.top = state.popupPosition.top + 'px'; + } + + const targetRow = document.createElement( 'div' ); + targetRow.className = 'target-row'; + const target = document.createElement( 'div' ); + target.className = 'target'; + target.textContent = state.target.elementLabel || state.target.tag; + target.title = [ state.target.selector, state.target.nearbyText ].filter( Boolean ).join( '\n' ); + targetRow.appendChild( target ); + if ( state.targets && state.targets.length > 1 ) { + const controls = document.createElement( 'div' ); + controls.className = 'layer-controls'; + const changeTarget = ( offset ) => { + state.targetIndex = + ( state.targetIndex + offset + state.targets.length ) % state.targets.length; + state.target = state.targets[ state.targetIndex ]; + render(); + }; + const previous = document.createElement( 'button' ); + previous.type = 'button'; + previous.className = 'layer-button'; + previous.textContent = '‹'; + previous.title = 'Select previous element at this point'; + previous.setAttribute( 'aria-label', previous.title ); + previous.addEventListener( 'click', () => changeTarget( -1 ) ); + const count = document.createElement( 'span' ); + count.className = 'layer-count'; + count.textContent = ( state.targetIndex + 1 ) + '/' + state.targets.length; + const next = document.createElement( 'button' ); + next.type = 'button'; + next.className = 'layer-button'; + next.textContent = '›'; + next.title = 'Select next element at this point'; + next.setAttribute( 'aria-label', next.title ); + next.addEventListener( 'click', () => changeTarget( 1 ) ); + controls.append( previous, count, next ); + targetRow.appendChild( controls ); + } + targetRow.addEventListener( 'mousedown', ( event ) => { + if ( event.button !== 0 || event.target.closest( 'button' ) ) return; + event.preventDefault(); + const startX = event.clientX; + const startY = event.clientY; + const startLeft = state.popupPosition.left; + const startTop = state.popupPosition.top; + let nextPosition = { left: startLeft, top: startTop }; + let animationFrame = null; + let didDrag = false; + popup.classList.add( 'dragging' ); + targetRow.classList.add( 'dragging' ); + const move = ( moveEvent ) => { + moveEvent.preventDefault(); + moveEvent.stopPropagation(); + if ( + Math.abs( moveEvent.clientX - startX ) > 2 || + Math.abs( moveEvent.clientY - startY ) > 2 + ) { + didDrag = true; + } + const width = popup.offsetWidth || Math.min( 360, window.innerWidth - 16 ); + const height = popup.offsetHeight || 150; + nextPosition = { + left: Math.min( + Math.max( 8, startLeft + moveEvent.clientX - startX ), + Math.max( 8, window.innerWidth - width - 8 ) + ), + top: Math.min( + Math.max( 8, startTop + moveEvent.clientY - startY ), + Math.max( 8, window.innerHeight - height - 8 ) + ), + }; + if ( animationFrame !== null ) return; + animationFrame = window.requestAnimationFrame( () => { + animationFrame = null; + popup.style.transform = + 'translate3d(' + + ( nextPosition.left - startLeft ) + + 'px,' + + ( nextPosition.top - startTop ) + + 'px,0)'; + } ); + }; + const stop = () => { + if ( animationFrame !== null ) window.cancelAnimationFrame( animationFrame ); + animationFrame = null; + state.popupPosition = nextPosition; + popup.style.left = state.popupPosition.left + 'px'; + popup.style.top = state.popupPosition.top + 'px'; + popup.style.transform = ''; + popup.classList.remove( 'dragging' ); + targetRow.classList.remove( 'dragging' ); + window.removeEventListener( 'mousemove', move, true ); + window.removeEventListener( 'mouseup', stop, true ); + window.removeEventListener( 'blur', stop, true ); + if ( didDrag ) { + const suppressClick = ( clickEvent ) => { + clickEvent.preventDefault(); + clickEvent.stopPropagation(); + }; + window.addEventListener( 'click', suppressClick, { capture: true, once: true } ); + setTimeout( () => window.removeEventListener( 'click', suppressClick, true ), 0 ); + } + }; + window.addEventListener( 'mousemove', move, true ); + window.addEventListener( 'mouseup', stop, true ); + window.addEventListener( 'blur', stop, true ); + } ); + popup.appendChild( targetRow ); + + const ta = document.createElement( 'textarea' ); + ta.placeholder = 'What should change about this element?'; + ta.maxLength = 10000; + ta.value = state.comment || ''; + const resizeTextarea = () => { + ta.style.height = '24px'; + ta.style.height = Math.min( ta.scrollHeight, 100 ) + 'px'; + }; + ta.addEventListener( 'input', () => { + state.comment = ta.value; + syncActions(); + resizeTextarea(); + } ); + popup.appendChild( ta ); + setTimeout( () => { + resizeTextarea(); + ta.focus(); + }, 0 ); + + const actions = document.createElement( 'div' ); + actions.className = 'actions'; + const leftActions = document.createElement( 'div' ); + leftActions.className = 'left-actions'; + + const closePopup = () => { + activePopup = null; + hoveredEl = null; + persistAnnotations(); + render(); + }; + + const cancel = document.createElement( 'button' ); + cancel.type = 'button'; + cancel.className = 'cancel'; + cancel.textContent = 'Cancel'; + cancel.addEventListener( 'click', closePopup ); + leftActions.appendChild( cancel ); + + if ( state.id ) { + const del = document.createElement( 'button' ); + del.type = 'button'; + del.className = 'delete'; + del.textContent = 'Delete'; + del.addEventListener( 'click', () => { + annotations = annotations.filter( ( a ) => a.id !== state.id ); + activePopup = null; + persistAnnotations(); + render(); + } ); + leftActions.appendChild( del ); + } + actions.appendChild( leftActions ); + + const sendToChat = document.createElement( 'button' ); + sendToChat.type = 'button'; + sendToChat.className = 'send-to-chat'; + sendToChat.textContent = EMBEDDED_TOOLBAR ? 'Send to agent' : 'Send to chat'; + sendToChat.addEventListener( 'click', submitAnnotations ); + actions.appendChild( sendToChat ); + + const save = document.createElement( 'button' ); + save.type = 'button'; + save.className = 'save'; + save.setAttribute( 'aria-label', state.id ? 'Update note' : 'Save note' ); + save.title = state.id ? 'Update note' : 'Save note'; + save.addEventListener( 'click', () => { + if ( ! commitActivePopup() ) return; + closePopup(); + } ); + actions.appendChild( save ); + + function syncActions() { + save.disabled = ! ( state.comment && state.comment.trim() ); + sendToChat.disabled = save.disabled && annotations.length === 0; + } + syncActions(); + + ta.addEventListener( 'keydown', ( event ) => { + if ( event.key !== 'Enter' || event.isComposing || event.keyCode === 229 ) return; + if ( event.metaKey || event.ctrlKey ) { + event.preventDefault(); + const start = ta.selectionStart; + const end = ta.selectionEnd; + ta.value = ta.value.slice( 0, start ) + '\n' + ta.value.slice( end ); + state.comment = ta.value; + ta.setSelectionRange( start + 1, start + 1 ); + syncActions(); + resizeTextarea(); + return; + } + if ( event.shiftKey ) return; + event.preventDefault(); + save.click(); + } ); + + popup.appendChild( actions ); + + popup.addEventListener( 'click', ( e ) => e.stopPropagation() ); + popup.addEventListener( 'mousemove', ( e ) => e.stopPropagation() ); + + return popup; + } + + function openPopupForAnnotation( ann ) { + hoveredEl = null; + activePopup = { + id: ann.id, + comment: ann.comment, + target: { + selector: ann.selector, + tag: ann.tag, + elementLabel: ann.elementLabel, + nearbyText: ann.nearbyText, + boundingBox: ann.boundingBox, + documentRect: ann.documentRect, + computedStyles: ann.computedStyles, + }, + }; + persistAnnotations(); + render(); + } + + function targetForElement( el ) { + const viewport = el.getBoundingClientRect(); + const classes = Array.from( el.classList || [] ) + .filter( ( className ) => ! className.startsWith( '__studio-' ) ) + .slice( 0, 2 ); + const elementLabel = ( + el.tagName.toLowerCase() + + ( el.id ? '#' + el.id : '' ) + + classes.map( ( className ) => '.' + className ).join( '' ) + ).slice( 0, 240 ); + const selector = buildSelector( el ); + return { + selector: selector.length <= 1000 ? selector : undefined, + tag: el.tagName.toLowerCase(), + elementLabel, + nearbyText: nearbyText( el ), + boundingBox: { x: viewport.x, y: viewport.y, width: viewport.width, height: viewport.height }, + documentRect: documentRect( el ), + computedStyles: pickComputedStyles( el ), + }; + } + + function elementsAtPoint( initial, clientX, clientY ) { + const MAX_CANDIDATES = 30; + const MAX_FALLBACK_ELEMENTS = 5000; + const MAX_FALLBACK_MS = 20; + const candidates = []; + const seen = new Set(); + const add = ( el ) => { + if ( candidates.length >= MAX_CANDIDATES || ! el || seen.has( el ) || isOurElement( el ) ) + return; + if ( el === document.documentElement || el === document.body ) return; + const rect = el.getBoundingClientRect(); + if ( rect.width <= 0 || rect.height <= 0 ) return; + const style = window.getComputedStyle( el ); + if ( style.display === 'none' || style.visibility === 'hidden' ) return; + seen.add( el ); + candidates.push( el ); + }; + + add( initial ); + if ( typeof document.elementsFromPoint === 'function' ) { + document.elementsFromPoint( clientX, clientY ).forEach( add ); + } + const behind = []; + const scanStartedAt = performance.now(); + let scannedElements = 0; + for ( const el of document.querySelectorAll( 'body *' ) ) { + scannedElements += 1; + if ( + scannedElements > MAX_FALLBACK_ELEMENTS || + ( scannedElements % 50 === 0 && performance.now() - scanStartedAt > MAX_FALLBACK_MS ) + ) { + break; + } + if ( seen.has( el ) || isOurElement( el ) ) continue; + const rect = el.getBoundingClientRect(); + if ( + rect.width > 0 && + rect.height > 0 && + clientX >= rect.left && + clientX <= rect.right && + clientY >= rect.top && + clientY <= rect.bottom + ) { + behind.push( { el, area: rect.width * rect.height } ); + } + } + behind.sort( ( a, b ) => a.area - b.area ).forEach( ( item ) => add( item.el ) ); + return candidates; + } + + function openPopupForElement( el, clientX, clientY ) { + const elements = elementsAtPoint( el, clientX, clientY ); + const targets = elements.map( targetForElement ); + isPicking = true; + activePopup = { + comment: '', + target: targets[ 0 ] || targetForElement( el ), + targets, + targetIndex: 0, + }; + persistAnnotations(); + render(); + } + + function isOurElement( el ) { + return !! ( el && el.closest && el.closest( '#' + HOST_ID ) ); + } + + document.addEventListener( + 'mousemove', + ( e ) => { + if ( ! isPicking || activePopup ) return; + if ( isOurElement( e.target ) ) { + if ( hoveredEl !== null ) { + hoveredEl = null; + showHighlight( null ); + } + return; + } + if ( hoveredEl !== e.target ) { + hoveredEl = e.target; + showHighlight( hoveredEl ); + } + }, + { capture: true, signal: teardown.signal } + ); + + document.addEventListener( + 'click', + ( e ) => { + if ( ! isPicking || activePopup ) return; + if ( isOurElement( e.target ) ) return; + e.preventDefault(); + e.stopPropagation(); + openPopupForElement( e.target, e.clientX, e.clientY ); + }, + { capture: true, signal: teardown.signal } + ); + + document.addEventListener( + 'keydown', + ( e ) => { + const browserCommand = EMBEDDED_TOOLBAR ? null : getBrowserShortcutCommand( e ); + if ( browserCommand ) { + e.preventDefault(); + e.stopPropagation(); + send( { type: 'browser-command', command: browserCommand } ); + return; + } + if ( e.key !== 'Escape' ) return; + if ( activePopup ) { + activePopup = null; + hoveredEl = null; + persistAnnotations(); + render(); + } else if ( isPicking ) { + isPicking = false; + hoveredEl = null; + persistAnnotations(); + render(); + } + }, + { capture: true, signal: teardown.signal } + ); + + function syncOverlayPositions() { + syncMarkers(); + syncAnnotationHighlights(); + if ( highlightNode ) { + const rect = activePopup + ? resolveTargetRect( activePopup.target ) + : hoveredEl + ? documentRect( hoveredEl ) + : null; + if ( rect ) positionHighlight( highlightNode, rect ); + } + syncScrim(); + } + + window.addEventListener( 'scroll', syncOverlayPositions, { + capture: true, + signal: teardown.signal, + } ); + window.addEventListener( 'resize', syncOverlayPositions, { signal: teardown.signal } ); + + render(); +} )(); +`;