Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 35 additions & 12 deletions packages/runtime-playground/src/browser-annotations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
Expand Down
4 changes: 2 additions & 2 deletions packages/runtime-playground/src/browser-presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<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>';
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';
Expand Down
2 changes: 2 additions & 0 deletions tests/browser-actions-presentation.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
32 changes: 32 additions & 0 deletions tests/browser-annotations.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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<void>((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('<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>')
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() }
})
Loading