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() }
+})