Skip to content

Commit 1bbf423

Browse files
fix(annotate): aim arrows at the target edge, keep labels clear of neighbours, hide the pointer until first move (#2553) (#2554)
Co-authored-by: homeboy-ci[bot] <266378653+homeboy-ci[bot]@users.noreply.github.com>
1 parent 09684d6 commit 1bbf423

4 files changed

Lines changed: 71 additions & 14 deletions

File tree

‎packages/runtime-playground/src/browser-annotations.ts‎

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -74,22 +74,45 @@ export async function executeBrowserAnnotation(page: Page, step: BrowserInteract
7474
const padding = Number(spec.style?.padding ?? 0)
7575
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` })
7676
}
77-
function render() {
78-
const value = rect()
79-
if (!value) return
80-
if (spec.shape === "highlight") setRect(value)
81-
if (spec.shape === "label") Object.assign(node.style, { left: `${value.left}px`, top: `${spec.anchor?.placement === "bottom" ? value.bottom + 8 : value.top - 44}px` })
82-
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)"}) }
83-
if (spec.shape === "arrow") {
84-
const direction = spec.direction || "bottom-left"
85-
const [vertical, horizontal] = direction.split("-").length === 2 ? direction.split("-") : [direction, "center"]
86-
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` })
87-
}
77+
function render() {
78+
const value = rect()
79+
if (!value) return
80+
if (spec.shape === "highlight") setRect(value)
81+
if (spec.shape === "label") {
82+
const gap = 12
83+
const width = node.offsetWidth
84+
const height = node.offsetHeight
85+
const placement = spec.anchor?.placement === "bottom" ? "bottom" : "top"
86+
const canPlace = (side: string) => side === "top" ? value.top >= height + gap : innerHeight - value.bottom >= height + gap
87+
const opposite = placement === "top" ? "bottom" : "top"
88+
const side = canPlace(placement) ? placement : canPlace(opposite) ? opposite : placement
89+
const left = Math.max(8, Math.min(value.left, innerWidth - width - 8))
90+
const top = Math.max(8, Math.min(side === "bottom" ? value.bottom + gap : value.top - height - gap, innerHeight - height - 8))
91+
Object.assign(node.style, { left: `${left}px`, top: `${top}px` })
92+
}
93+
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)"}) }
94+
if (spec.shape === "arrow") {
95+
const direction = spec.direction || "bottom-left"
96+
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]
97+
const length = 56
98+
const ux = dx / Math.hypot(dx, dy), uy = dy / Math.hypot(dx, dy)
99+
const endX = value.left + value.width / 2 - ux * (dx && dy ? value.width / 2 / Math.SQRT2 : dx ? value.width / 2 : 0)
100+
const endY = value.top + value.height / 2 - uy * (dx && dy ? value.height / 2 / Math.SQRT2 : dy ? value.height / 2 : 0)
101+
const startX = endX + ux * length, startY = endY + uy * length
102+
const bounds = { left: Math.min(startX, endX) - 10, top: Math.min(startY, endY) - 10 }
103+
node.style.left = `${bounds.left}px`; node.style.top = `${bounds.top}px`
104+
const svg = node.querySelector("svg")!
105+
const shaft = svg.querySelector("path")!
106+
const sx = startX - bounds.left, sy = startY - bounds.top, ex = endX - bounds.left, ey = endY - bounds.top
107+
const head = 10, angle = Math.atan2(ey - sy, ex - sx), spread = Math.PI / 6
108+
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)}`)
109+
svg.setAttribute("width", "80"); svg.setAttribute("height", "80"); svg.setAttribute("viewBox", "0 0 80 80")
110+
}
88111
}
89112
Object.assign(node.style, { position: "fixed", boxSizing: "border-box", pointerEvents: "none", transition: spec.animate === "fade" || spec.animate === undefined ? "opacity .25s ease" : "none", opacity: "1" })
90113
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" })
91114
if (spec.shape === "spotlight") Object.assign(node.style, { inset: "0" })
92-
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"}) }
115+
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) }
93116
if (spec.shape === "label" || spec.shape === "caption") {
94117
node.textContent = spec.text || ""
95118
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" })

‎packages/runtime-playground/src/browser-presentation.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,13 @@ export function browserPresentationInitScript(presentation: BrowserPresentation)
1717
const cursor = document.createElement('div');
1818
const pointer = config.pointer || {};
1919
const size = Number(pointer.size) || 20;
20-
cursor.style.cssText = 'position:fixed;left:0;top:0;pointer-events:none;transform:translate(-2px,-2px);display:' + (pointer.enabled === false ? 'none' : 'block') + ';';
20+
cursor.style.cssText = 'position:fixed;left:0;top:0;pointer-events:none;transform:translate(-2px,-2px);visibility:hidden;display:' + (pointer.enabled === false ? 'none' : 'block') + ';';
2121
if (pointer.style === 'dot') cursor.style.cssText += 'width:'+size+'px;height:'+size+'px;border-radius:50%;background:'+(pointer.color||'#e11d48')+';';
2222
else if (pointer.style === 'touch') cursor.style.cssText += 'width:'+size+'px;height:'+size+'px;border:2px solid '+(pointer.color||'#e11d48')+';border-radius:50%;';
2323
else cursor.innerHTML = '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24"><path fill="'+(pointer.color||'#111')+'" stroke="white" d="M3 2l7 19 3-7 7-3z"/></svg>';
2424
shadow.append(cursor);
2525
document.documentElement.append(host);
26-
window.addEventListener('mousemove', event => { cursor.style.left=event.clientX+'px'; cursor.style.top=event.clientY+'px'; }, true);
26+
window.addEventListener('mousemove', event => { cursor.style.left=event.clientX+'px'; cursor.style.top=event.clientY+'px'; cursor.style.visibility='visible'; }, true);
2727
window.addEventListener('click', event => {
2828
if (!config.clickFeedback) return;
2929
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';

‎tests/browser-actions-presentation.browser.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ test("presentation overlay survives navigation without intercepting clicks and s
2626
})
2727
assert.equal(overlay.exists, true)
2828
assert.equal(overlay.hit, false)
29+
assert.match(browserPresentationInitScript({ pointer: { enabled: true } }), /visibility:hidden/)
30+
assert.match(browserPresentationInitScript({ pointer: { enabled: true } }), /cursor\.style\.visibility='visible'/)
2931
await executeBrowserInteractionStep(page, { kind: "scroll", selector: "#target", behavior: "smooth" }, page.url(), 3_000, async () => ({ path: "unused", isDefault: false }))
3032
const visible = await page.locator("#target").evaluate(element => {
3133
const rect = element.getBoundingClientRect()

‎tests/browser-annotations.browser.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import { createServer } from "node:http"
44
import { tmpdir } from "node:os"
55
import { join } from "node:path"
66
import test from "node:test"
7+
import { chromium } from "playwright"
78
import { validateBrowserInteractionScript } from "../packages/runtime-core/src/browser-interaction.js"
9+
import { executeBrowserAnnotation } from "../packages/runtime-playground/src/browser-annotations.js"
810
import { runBrowserActionsCommand } from "../packages/runtime-playground/src/browser-actions-runner.js"
911
import { wordpressRuntimeSpec } from "../scripts/test-kit.js"
1012

@@ -41,3 +43,33 @@ test("annotation renders in isolated overlay, follows scrolling, passes clicks,
4143
assert.equal(records[5].status, "ok", "annotation should clear by id")
4244
} finally { await rm(artifactRoot, { recursive: true, force: true }); await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve())) }
4345
})
46+
47+
test("arrows point to the target edge and labels avoid adjacent controls", async () => {
48+
const browser = await chromium.launch({ headless: true })
49+
const page = await browser.newPage({ viewport: { width: 600, height: 400 } })
50+
try {
51+
await page.setContent('<button id="above" style="position:absolute;left:200px;top:80px">Above</button><button id="target" style="position:absolute;left:200px;top:150px;width:120px;height:40px">Target</button><button id="below" style="position:absolute;left:200px;top:230px">Below</button>')
52+
for (const direction of ["left", "right", "top", "bottom"]) {
53+
await executeBrowserAnnotation(page, { kind: "annotate", id: direction, shape: "arrow", selector: "#target", direction } as never)
54+
const error = await page.evaluate((id) => {
55+
const target = document.querySelector("#target")!.getBoundingClientRect()
56+
const node = document.querySelector("#__wp_codebox_annotations")!.shadowRoot!.querySelector(`[data-annotation-id="${id}"]`)!
57+
const path = node.querySelector("path")!
58+
const match = path.getAttribute("d")!.match(/M ([\d.]+) ([\d.]+) L ([\d.]+) ([\d.]+)/)!
59+
const bounds = node.getBoundingClientRect()
60+
const x = bounds.left + Number(match[3]), y = bounds.top + Number(match[4])
61+
return Math.min(Math.abs(x - target.left), Math.abs(x - target.right), Math.abs(y - target.top), Math.abs(y - target.bottom))
62+
}, direction)
63+
assert.ok(error <= 1, `${direction} arrow tip should land on the target edge`)
64+
}
65+
await executeBrowserAnnotation(page, { kind: "annotate", id: "label", shape: "label", text: "Continue", anchor: { selector: "#target", placement: "top" } } as never)
66+
const overlap = await page.evaluate(() => {
67+
const label = document.querySelector("#__wp_codebox_annotations")!.shadowRoot!.querySelector('[data-annotation-id="label"]')!.getBoundingClientRect()
68+
return ["#above", "#target", "#below"].some(selector => {
69+
const box = document.querySelector(selector)!.getBoundingClientRect()
70+
return label.left < box.right && label.right > box.left && label.top < box.bottom && label.bottom > box.top
71+
})
72+
})
73+
assert.equal(overlap, false)
74+
} finally { await browser.close() }
75+
})

0 commit comments

Comments
 (0)