From c3b3af5891f0075d722d90a8536dc121714c3d15 Mon Sep 17 00:00:00 2001 From: "homeboy-ci[bot]" <266378653+homeboy-ci[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:33:56 +0000 Subject: [PATCH] fix(annotate): aim arrows at the target edge, keep labels clear of neighbours, hide the pointer until first move (#2553) --- .../src/browser-annotations.ts | 47 ++++++++++++++----- .../src/browser-presentation.ts | 4 +- ...owser-actions-presentation.browser.test.ts | 2 + tests/browser-annotations.browser.test.ts | 32 +++++++++++++ 4 files changed, 71 insertions(+), 14 deletions(-) diff --git a/packages/runtime-playground/src/browser-annotations.ts b/packages/runtime-playground/src/browser-annotations.ts index a850fa95..282d3d25 100644 --- a/packages/runtime-playground/src/browser-annotations.ts +++ b/packages/runtime-playground/src/browser-annotations.ts @@ -74,22 +74,45 @@ export async function executeBrowserAnnotation(page: Page, step: BrowserInteract const padding = Number(spec.style?.padding ?? 0) Object.assign(node.style, { left: `${value.left - padding}px`, top: `${value.top - padding}px`, width: `${value.width + padding * 2}px`, height: `${value.height + padding * 2}px` }) } - function render() { - const value = rect() - if (!value) return - if (spec.shape === "highlight") setRect(value) - if (spec.shape === "label") Object.assign(node.style, { left: `${value.left}px`, top: `${spec.anchor?.placement === "bottom" ? value.bottom + 8 : value.top - 44}px` }) - if (spec.shape === "spotlight") { const p = Number(spec.style?.padding ?? 8); const l=value.left-p,t=value.top-p,w=value.width+p*2,h=value.height+p*2; Object.assign(node.style,{clipPath:`polygon(0 0,100% 0,100% 100%,0 100%,0 0,${l}px ${t}px,${l}px ${t+h}px,${l+w}px ${t+h}px,${l+w}px ${t}px,${l}px ${t}px)`,background:"rgba(0,0,0,.62)"}) } - if (spec.shape === "arrow") { - const direction = spec.direction || "bottom-left" - const [vertical, horizontal] = direction.split("-").length === 2 ? direction.split("-") : [direction, "center"] - Object.assign(node.style, { left: `${horizontal === "left" ? value.left - 50 : horizontal === "right" ? value.right + 10 : value.left + value.width / 2}px`, top: `${vertical === "top" ? value.top - 50 : vertical === "bottom" ? value.bottom + 10 : value.top + value.height / 2}px` }) - } + function render() { + const value = rect() + if (!value) return + if (spec.shape === "highlight") setRect(value) + if (spec.shape === "label") { + const gap = 12 + const width = node.offsetWidth + const height = node.offsetHeight + const placement = spec.anchor?.placement === "bottom" ? "bottom" : "top" + const canPlace = (side: string) => side === "top" ? value.top >= height + gap : innerHeight - value.bottom >= height + gap + const opposite = placement === "top" ? "bottom" : "top" + const side = canPlace(placement) ? placement : canPlace(opposite) ? opposite : placement + const left = Math.max(8, Math.min(value.left, innerWidth - width - 8)) + const top = Math.max(8, Math.min(side === "bottom" ? value.bottom + gap : value.top - height - gap, innerHeight - height - 8)) + Object.assign(node.style, { left: `${left}px`, top: `${top}px` }) + } + if (spec.shape === "spotlight") { const p = Number(spec.style?.padding ?? 8); const l=value.left-p,t=value.top-p,w=value.width+p*2,h=value.height+p*2; Object.assign(node.style,{clipPath:`polygon(0 0,100% 0,100% 100%,0 100%,0 0,${l}px ${t}px,${l}px ${t+h}px,${l+w}px ${t+h}px,${l+w}px ${t}px,${l}px ${t}px)`,background:"rgba(0,0,0,.62)"}) } + if (spec.shape === "arrow") { + const direction = spec.direction || "bottom-left" + const [dx, dy] = direction === "left" ? [-1, 0] : direction === "right" ? [1, 0] : direction === "top" ? [0, -1] : direction === "bottom" ? [0, 1] : direction === "top-left" ? [-1, -1] : direction === "top-right" ? [1, -1] : direction === "bottom-right" ? [1, 1] : [-1, 1] + const length = 56 + const ux = dx / Math.hypot(dx, dy), uy = dy / Math.hypot(dx, dy) + const endX = value.left + value.width / 2 - ux * (dx && dy ? value.width / 2 / Math.SQRT2 : dx ? value.width / 2 : 0) + const endY = value.top + value.height / 2 - uy * (dx && dy ? value.height / 2 / Math.SQRT2 : dy ? value.height / 2 : 0) + const startX = endX + ux * length, startY = endY + uy * length + const bounds = { left: Math.min(startX, endX) - 10, top: Math.min(startY, endY) - 10 } + node.style.left = `${bounds.left}px`; node.style.top = `${bounds.top}px` + const svg = node.querySelector("svg")! + const shaft = svg.querySelector("path")! + const sx = startX - bounds.left, sy = startY - bounds.top, ex = endX - bounds.left, ey = endY - bounds.top + const head = 10, angle = Math.atan2(ey - sy, ex - sx), spread = Math.PI / 6 + shaft.setAttribute("d", `M ${sx} ${sy} L ${ex} ${ey} M ${ex - head * Math.cos(angle - spread)} ${ey - head * Math.sin(angle - spread)} L ${ex} ${ey} L ${ex - head * Math.cos(angle + spread)} ${ey - head * Math.sin(angle + spread)}`) + svg.setAttribute("width", "80"); svg.setAttribute("height", "80"); svg.setAttribute("viewBox", "0 0 80 80") + } } Object.assign(node.style, { position: "fixed", boxSizing: "border-box", pointerEvents: "none", transition: spec.animate === "fade" || spec.animate === undefined ? "opacity .25s ease" : "none", opacity: "1" }) if (spec.shape === "highlight") Object.assign(node.style, { border: `${Number(theme.strokeWidth ?? 3)}px solid ${color}`, borderRadius: spec.style?.variant === "box" ? `${Number(theme.radius ?? 0)}px` : "999px" }) if (spec.shape === "spotlight") Object.assign(node.style, { inset: "0" }) - if (spec.shape === "arrow") { const svg=document.createElementNS("http://www.w3.org/2000/svg","svg"); svg.setAttribute("width","88");svg.setAttribute("height","64");svg.setAttribute("viewBox","0 0 88 64");const path=document.createElementNS(svg.namespaceURI,"path");path.setAttribute("d","M4 56 Q30 52 72 12 M54 12 L72 12 L72 30");path.setAttribute("fill","none");path.setAttribute("stroke",color);path.setAttribute("stroke-width",String(theme.strokeWidth??3));path.setAttribute("stroke-linecap","round");path.setAttribute("stroke-linejoin","round");if(spec.animate==="draw"){path.setAttribute("stroke-dasharray","120");path.setAttribute("stroke-dashoffset","120");path.animate([{strokeDashoffset:"120"},{strokeDashoffset:"0"}],{duration:600,fill:"forwards"})}svg.append(path);node.append(svg);Object.assign(node.style,{transform:spec.direction?.startsWith("top")?"rotate(180deg)":spec.direction?.startsWith("left")?"rotate(90deg)":spec.direction?.startsWith("right")?"rotate(-90deg)":"none"}) } + if (spec.shape === "arrow") { const svg=document.createElementNS("http://www.w3.org/2000/svg","svg"); svg.setAttribute("width","80");svg.setAttribute("height","80");svg.setAttribute("viewBox","0 0 80 80");const path=document.createElementNS(svg.namespaceURI,"path");path.setAttribute("fill","none");path.setAttribute("stroke",color);path.setAttribute("stroke-width",String(theme.strokeWidth??3));path.setAttribute("stroke-linecap","round");path.setAttribute("stroke-linejoin","round");if(spec.animate==="draw"){path.setAttribute("stroke-dasharray","120");path.setAttribute("stroke-dashoffset","120");path.animate([{strokeDashoffset:"120"},{strokeDashoffset:"0"}],{duration:600,fill:"forwards"})}svg.append(path);node.append(svg) } if (spec.shape === "label" || spec.shape === "caption") { node.textContent = spec.text || "" Object.assign(node.style, { padding: "8px 12px", borderRadius: `${Number(theme.radius ?? 6)}px`, color: textColor, background, fontFamily: theme.fontFamily ?? "system-ui, sans-serif", fontSize: `${Number(theme.fontSize ?? 16)}px`, whiteSpace: "nowrap" }) diff --git a/packages/runtime-playground/src/browser-presentation.ts b/packages/runtime-playground/src/browser-presentation.ts index aff92131..fb1d9752 100644 --- a/packages/runtime-playground/src/browser-presentation.ts +++ b/packages/runtime-playground/src/browser-presentation.ts @@ -17,13 +17,13 @@ export function browserPresentationInitScript(presentation: BrowserPresentation) const cursor = document.createElement('div'); const pointer = config.pointer || {}; const size = Number(pointer.size) || 20; - cursor.style.cssText = 'position:fixed;left:0;top:0;pointer-events:none;transform:translate(-2px,-2px);display:' + (pointer.enabled === false ? 'none' : 'block') + ';'; + cursor.style.cssText = 'position:fixed;left:0;top:0;pointer-events:none;transform:translate(-2px,-2px);visibility:hidden;display:' + (pointer.enabled === false ? 'none' : 'block') + ';'; if (pointer.style === 'dot') cursor.style.cssText += 'width:'+size+'px;height:'+size+'px;border-radius:50%;background:'+(pointer.color||'#e11d48')+';'; else if (pointer.style === 'touch') cursor.style.cssText += 'width:'+size+'px;height:'+size+'px;border:2px solid '+(pointer.color||'#e11d48')+';border-radius:50%;'; else cursor.innerHTML = ''; shadow.append(cursor); document.documentElement.append(host); - window.addEventListener('mousemove', event => { cursor.style.left=event.clientX+'px'; cursor.style.top=event.clientY+'px'; }, true); + window.addEventListener('mousemove', event => { cursor.style.left=event.clientX+'px'; cursor.style.top=event.clientY+'px'; cursor.style.visibility='visible'; }, true); window.addEventListener('click', event => { if (!config.clickFeedback) return; const ring=document.createElement('div'); ring.style.cssText='position:fixed;left:'+event.clientX+'px;top:'+event.clientY+'px;width:12px;height:12px;border:2px solid '+(pointer.color||'#e11d48')+';border-radius:50%;transform:translate(-50%,-50%);animation:presentation-ripple .55s ease-out forwards'; diff --git a/tests/browser-actions-presentation.browser.test.ts b/tests/browser-actions-presentation.browser.test.ts index 4b0f7df0..6a41e514 100644 --- a/tests/browser-actions-presentation.browser.test.ts +++ b/tests/browser-actions-presentation.browser.test.ts @@ -26,6 +26,8 @@ test("presentation overlay survives navigation without intercepting clicks and s }) assert.equal(overlay.exists, true) assert.equal(overlay.hit, false) + assert.match(browserPresentationInitScript({ pointer: { enabled: true } }), /visibility:hidden/) + assert.match(browserPresentationInitScript({ pointer: { enabled: true } }), /cursor\.style\.visibility='visible'/) await executeBrowserInteractionStep(page, { kind: "scroll", selector: "#target", behavior: "smooth" }, page.url(), 3_000, async () => ({ path: "unused", isDefault: false })) const visible = await page.locator("#target").evaluate(element => { const rect = element.getBoundingClientRect() diff --git a/tests/browser-annotations.browser.test.ts b/tests/browser-annotations.browser.test.ts index 62e21972..60704676 100644 --- a/tests/browser-annotations.browser.test.ts +++ b/tests/browser-annotations.browser.test.ts @@ -4,7 +4,9 @@ import { createServer } from "node:http" import { tmpdir } from "node:os" import { join } from "node:path" import test from "node:test" +import { chromium } from "playwright" import { validateBrowserInteractionScript } from "../packages/runtime-core/src/browser-interaction.js" +import { executeBrowserAnnotation } from "../packages/runtime-playground/src/browser-annotations.js" import { runBrowserActionsCommand } from "../packages/runtime-playground/src/browser-actions-runner.js" import { wordpressRuntimeSpec } from "../scripts/test-kit.js" @@ -41,3 +43,33 @@ test("annotation renders in isolated overlay, follows scrolling, passes clicks, assert.equal(records[5].status, "ok", "annotation should clear by id") } finally { await rm(artifactRoot, { recursive: true, force: true }); await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())) } }) + +test("arrows point to the target edge and labels avoid adjacent controls", async () => { + const browser = await chromium.launch({ headless: true }) + const page = await browser.newPage({ viewport: { width: 600, height: 400 } }) + try { + await page.setContent('') + for (const direction of ["left", "right", "top", "bottom"]) { + await executeBrowserAnnotation(page, { kind: "annotate", id: direction, shape: "arrow", selector: "#target", direction } as never) + const error = await page.evaluate((id) => { + const target = document.querySelector("#target")!.getBoundingClientRect() + const node = document.querySelector("#__wp_codebox_annotations")!.shadowRoot!.querySelector(`[data-annotation-id="${id}"]`)! + const path = node.querySelector("path")! + const match = path.getAttribute("d")!.match(/M ([\d.]+) ([\d.]+) L ([\d.]+) ([\d.]+)/)! + const bounds = node.getBoundingClientRect() + const x = bounds.left + Number(match[3]), y = bounds.top + Number(match[4]) + return Math.min(Math.abs(x - target.left), Math.abs(x - target.right), Math.abs(y - target.top), Math.abs(y - target.bottom)) + }, direction) + assert.ok(error <= 1, `${direction} arrow tip should land on the target edge`) + } + await executeBrowserAnnotation(page, { kind: "annotate", id: "label", shape: "label", text: "Continue", anchor: { selector: "#target", placement: "top" } } as never) + const overlap = await page.evaluate(() => { + const label = document.querySelector("#__wp_codebox_annotations")!.shadowRoot!.querySelector('[data-annotation-id="label"]')!.getBoundingClientRect() + return ["#above", "#target", "#below"].some(selector => { + const box = document.querySelector(selector)!.getBoundingClientRect() + return label.left < box.right && label.right > box.left && label.top < box.bottom && label.bottom > box.top + }) + }) + assert.equal(overlap, false) + } finally { await browser.close() } +})