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
23 changes: 23 additions & 0 deletions docs/src/api/class-screencast.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ Position of the action title overlay. Defaults to `"top-right"`.

### option: Screencast.showActions.fontSize
* since: v1.59
* deprecated: Use `title` in [`option: Screencast.showActions.style`] instead, for example `style: { title: 'font-size: 32px' }`.
- `fontSize` ?<[int]>

Font size of the action title in pixels. Defaults to `24`.
Expand All @@ -151,6 +152,28 @@ Cursor decoration shown for pointer actions. `"pointer"` (the default) renders
a mouse pointer that animates from the previous action point to the next one.
`"none"` disables the cursor decoration.

### option: Screencast.showActions.style
* since: v1.64
- `style` ?<[Object]>
* alias-csharp: ScreencastActionStyle
- `point` ?<[string]> CSS declarations for the marker at the action point. The marker is positioned at the action point, has zero size and is centered on the point, so its size and look come from this style. Not shown when omitted.
- `highlight` ?<[string]> CSS declarations for the box that covers the target element. The box is positioned and sized to the element bounds. Not shown when omitted.
- `title` ?<[string]> CSS declarations for the action title, for example `'font-size: 32px; background: #333'`. The title is placed according to [`option: Screencast.showActions.position`].

Styles of the action decorations. All decorations fade out over [`option: Screencast.showActions.duration`].

**Usage**

```js
await page.screencast.showActions({
style: {
point: 'width: 20px; height: 20px; border-radius: 50%; background: red',
highlight: 'outline: 2px solid #333; background: rgba(0, 128, 255, .15)',
title: 'font-size: 16px',
},
});
```

## async method: Screencast.showOverlays
* since: v1.59

Expand Down
6 changes: 5 additions & 1 deletion docs/src/api/params.md
Original file line number Diff line number Diff line change
Expand Up @@ -862,8 +862,12 @@ When set to `minimal`, only record information necessary for routing from HAR. T
- `showActions` ?<[Object]> If specified, enables visual annotations on interacted elements during video recording.
- `duration` ?<[float]> How long each annotation is displayed in milliseconds. Defaults to `500`.
- `position` ?<[AnnotatePosition]<"top-left"|"top"|"top-right"|"bottom-left"|"bottom"|"bottom-right">> Position of the action title overlay. Defaults to `"top-right"`.
- `fontSize` ?<[int]> Font size of the action title in pixels. Defaults to `24`.
- `fontSize` ?<[int]> Font size of the action title in pixels. Defaults to `24`. Deprecated, use `style.title` instead.
- `cursor` ?<[ScreencastCursor]<"none"|"pointer">> Cursor decoration shown for pointer actions. `"pointer"` (the default) renders a mouse pointer that animates from the previous action point to the next one. `"none"` disables the cursor decoration.
- `style` ?<[Object]> Styles of the action decorations.
- `point` ?<[string]> CSS declarations for the zero-sized marker centered on the action point. Not shown when omitted.
- `highlight` ?<[string]> CSS declarations for the box that covers the target element. Not shown when omitted.
- `title` ?<[string]> CSS declarations for the action title.

Enables video recording for all pages into `recordVideo.dir` directory. If not specified videos are not recorded. Make
sure to await [`method: BrowserContext.close`] for videos to be saved.
Expand Down
6 changes: 5 additions & 1 deletion docs/src/test-api/class-testoptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -730,8 +730,12 @@ export default defineConfig({
- `actions` ?<[Object]> Controls visual annotations on interacted elements.
- `duration` ?<[float]> How long each annotation is displayed in milliseconds. Defaults to `500`.
- `position` ?<[AnnotatePosition]<"top-left"|"top"|"top-right"|"bottom-left"|"bottom"|"bottom-right">> Position of the action title overlay. Defaults to `"top-right"`.
- `fontSize` ?<[int]> Font size of the action title in pixels. Defaults to `24`.
- `fontSize` ?<[int]> Font size of the action title in pixels. Defaults to `24`. Deprecated, use `style.title` instead.
- `cursor` ?<[ScreencastCursor]<"none"|"pointer">> Cursor decoration shown for pointer actions. `"pointer"` (the default) renders a mouse pointer that animates from the previous action point to the next one. `"none"` disables the cursor decoration.
- `style` ?<[Object]> Styles of the action decorations.
- `point` ?<[string]> CSS declarations for the zero-sized marker centered on the action point. Not shown when omitted.
- `highlight` ?<[string]> CSS declarations for the box that covers the target element. Not shown when omitted.
- `title` ?<[string]> CSS declarations for the action title.
- `test` ?<[Object]> Controls test information displayed as a status overlay in the video.
- `level` ?<[TestAnnotationLevel]<"file"|"test"|"step">> Level of the detail to include about the current test.
- `position` ?<[AnnotatePosition]<"top-left"|"top"|"top-right"|"bottom-left"|"bottom"|"bottom-right">> Position of the test information overlay. Defaults to `"top-left"`.
Expand Down
32 changes: 29 additions & 3 deletions packages/injected/src/highlight.css
Original file line number Diff line number Diff line change
Expand Up @@ -108,19 +108,45 @@ x-pw-action-point {
z-index: 2;
}

x-pw-screencast-highlight,
x-pw-screencast-point,
x-pw-title {
animation: pw-fade-out var(--pw-fade-duration) ease-out var(--pw-fade-delay, 0ms) forwards;
}

x-pw-screencast-highlight,
x-pw-screencast-point {
position: absolute;
pointer-events: none;
}

x-pw-screencast-highlight {
z-index: 1;
}

/* Zero-sized and centered on the action point, user style draws around it. */
x-pw-screencast-point {
width: 0;
height: 0;
overflow: visible;
translate: -50% -50%;
z-index: 2;
}

x-pw-action-cursor {
position: absolute;
width: 18px;
height: 22px;
width: 14px;
height: 21px;
pointer-events: none;
z-index: 4;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.4));
filter: drop-shadow(0 1px 1.5px rgba(0, 0, 0, 0.35));
}

x-pw-action-cursor svg {
width: 100%;
height: 100%;
position: static;
overflow: visible;
}

x-pw-title {
Expand Down
136 changes: 98 additions & 38 deletions packages/injected/src/highlight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,6 @@ type Rect = { x: number, y: number, width: number, height: number };
type RenderedHighlightEntry = {
targetElement?: Element,
color: string,
borderColor?: string,
fadeDuration?: number,
highlightElement: HTMLElement,
tooltipElement?: HTMLElement,
box?: DOMRect,
Expand All @@ -44,8 +42,6 @@ export type HighlightEntry = {
element?: Element,
box?: Rect,
color: string,
borderColor?: string,
fadeDuration?: number,
tooltipText?: string,
cssStyle?: string,
};
Expand All @@ -56,6 +52,8 @@ export class Highlight {
private _renderedEntries: RenderedHighlightEntry[] = [];
private _actionPointElement: HTMLElement | undefined;
private _actionCursorElement: HTMLElement;
private _actionCursorAnimation: Animation | undefined;
private _screencastDecorations: HTMLElement[] = [];
private _titleElement: HTMLElement;
private _userOverlayContainer: HTMLElement;
private _userOverlays = new Map<string, HTMLElement>();
Expand Down Expand Up @@ -179,31 +177,74 @@ export class Highlight {
this._glassPaneElement.remove();
}

showActionPoint(x: number, y: number, fadeDuration?: number) {
showActionPoint(x: number, y: number) {
if (!this._actionPointElement) {
this._actionPointElement = this._injectedScript.document.createElement('x-pw-action-point');
this._glassPaneShadow.appendChild(this._actionPointElement);
}
this._actionPointElement.style.top = y + 'px';
this._actionPointElement.style.left = x + 'px';
this._actionPointElement.hidden = false;
if (fadeDuration)
this._actionPointElement.style.animation = `pw-fade-out ${fadeDuration}ms ease-out forwards`;
else
this._actionPointElement.style.animation = '';
}

hideActionPoint() {
if (this._actionPointElement)
this._actionPointElement.hidden = true;
}

showScreencastHighlight(box: Rect, style: string, fadeDuration: number) {
const element = this._createScreencastDecoration('x-pw-screencast-highlight', style, fadeDuration);
element.style.left = box.x + 'px';
element.style.top = box.y + 'px';
element.style.width = box.width + 'px';
element.style.height = box.height + 'px';
}

showScreencastPoint(x: number, y: number, style: string, fadeDuration: number) {
const element = this._createScreencastDecoration('x-pw-screencast-point', style, fadeDuration);
element.style.left = x + 'px';
element.style.top = y + 'px';
}

hideScreencastDecorations() {
for (const element of this._screencastDecorations)
element.remove();
this._screencastDecorations = [];
}

private _createScreencastDecoration(name: string, style: string, fadeDuration: number): HTMLElement {
const element = this._injectedScript.document.createElement(name);
// User style goes first so that the geometry assigned by the caller wins.
element.style.cssText = style;
if (fadeDuration)
element.style.setProperty('--pw-fade-duration', fadeDuration + 'ms');
this._glassPaneShadow.appendChild(element);
this._screencastDecorations.push(element);
return element;
}

moveActionCursor(x: number, y: number, fadeDuration?: number) {
const moveDuration = fadeDuration ? Math.max(80, Math.min(fadeDuration * 0.6, 400)) : 0;
this._actionCursorElement.style.transition = `top ${moveDuration}ms ease, left ${moveDuration}ms ease`;
this._actionCursorElement.style.left = x + 'px';
this._actionCursorElement.style.top = y + 'px';
this._actionCursorElement.style.visibility = 'visible';
const element = this._actionCursorElement;
// Start where the cursor is rendered, it might be in the middle of the previous move.
const from = element.getBoundingClientRect();
const wasVisible = element.style.visibility === 'visible';
this._actionCursorAnimation?.cancel();
this._actionCursorAnimation = undefined;
element.style.left = x + 'px';
element.style.top = y + 'px';
element.style.visibility = 'visible';
const dx = from.left - x;
const dy = from.top - y;
if (!wasVisible || !fadeDuration || Math.hypot(dx, dy) < 1)
return;
const duration = Math.max(80, Math.min(fadeDuration * 0.6, 400));
// Start slow, speed up in the middle and land gently.
this._actionCursorAnimation = element.animate(cursorPathKeyframes(dx, dy, x + y + dx + dy), { duration, easing: 'cubic-bezier(0.5, 0, 0.3, 1)' });
}

restoreActionCursor(x: number, y: number) {
if (this._actionCursorElement.style.visibility !== 'visible')
this.moveActionCursor(x, y);
}

hideActionCursor() {
Expand All @@ -213,34 +254,30 @@ export class Highlight {
private _createCursorSvg(document: Document): SVGSVGElement {
const svgNs = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(svgNs, 'svg');
svg.setAttribute('viewBox', '0 0 18 22');
// macOS-style arrow with the tip at the origin, the border is drawn outside of the fill.
svg.setAttribute('viewBox', '0 0 12 18');
const path = document.createElementNS(svgNs, 'path');
path.setAttribute('d', 'M1 1 L1 17 L5.5 13 L8 20.5 L11 19.5 L8.5 12 L15 12 Z');
path.setAttribute('fill', 'white');
path.setAttribute('stroke', 'black');
path.setAttribute('stroke-width', '1.5');
path.setAttribute('d', 'M0 0 L0 15.8 L3.9 12.4 L6.6 18.6 L8.9 17.6 L6.2 11.4 L11.2 11.4 Z');
path.setAttribute('fill', 'black');
path.setAttribute('stroke', 'white');
path.setAttribute('stroke-width', '1.6');
path.setAttribute('stroke-linejoin', 'round');
path.setAttribute('paint-order', 'stroke');
svg.appendChild(path);
return svg;
}

showActionTitle(text: string, fadeDuration: number, position?: string, fontSize?: number) {
showActionTitle(text: string, fadeDuration: number, position?: string, style?: string) {
this._titleElement.textContent = text;
this._titleElement.hidden = false;
// User style goes first so that the position assigned below wins.
this._titleElement.style.cssText = style ?? '';
if (fadeDuration) {
const fadeTime = fadeDuration / 4;
this._titleElement.style.animation = `pw-fade-out ${fadeTime}ms ease-out ${fadeDuration - fadeTime}ms forwards`;
} else {
this._titleElement.style.animation = '';
this._titleElement.style.setProperty('--pw-fade-duration', fadeTime + 'ms');
this._titleElement.style.setProperty('--pw-fade-delay', (fadeDuration - fadeTime) + 'ms');
}

// Reset positioning
this._titleElement.style.top = '';
this._titleElement.style.bottom = '';
this._titleElement.style.left = '';
this._titleElement.style.right = '';
this._titleElement.style.transform = '';

switch (position) {
case 'top-left':
this._titleElement.style.top = '6px';
Expand Down Expand Up @@ -270,9 +307,6 @@ export class Highlight {
this._titleElement.style.right = '6px';
break;
}

if (fontSize)
this._titleElement.style.fontSize = fontSize + 'px';
}

hideActionTitle() {
Expand Down Expand Up @@ -377,7 +411,7 @@ export class Highlight {
lineElement.textContent = entry.tooltipText;
tooltipElement.appendChild(lineElement);
}
this._renderedEntries.push({ targetElement: entry.element, box: toDOMRect(entry.box), color: entry.color, borderColor: entry.borderColor, fadeDuration: entry.fadeDuration, cssStyle: entry.cssStyle, tooltipElement, highlightElement });
this._renderedEntries.push({ targetElement: entry.element, box: toDOMRect(entry.box), color: entry.color, cssStyle: entry.cssStyle, tooltipElement, highlightElement });
}

// 2. Trigger layout while positioning tooltips and computing bounding boxes.
Expand Down Expand Up @@ -407,10 +441,6 @@ export class Highlight {
entry.highlightElement.style.width = box.width + 'px';
entry.highlightElement.style.height = box.height + 'px';
entry.highlightElement.style.display = 'block';
if (entry.borderColor)
entry.highlightElement.style.border = '2px solid ' + entry.borderColor;
if (entry.fadeDuration)
entry.highlightElement.style.animation = `pw-fade-out ${entry.fadeDuration}ms ease-out forwards`;
if (entry.cssStyle)
entry.highlightElement.style.cssText += ';' + entry.cssStyle;

Expand Down Expand Up @@ -504,6 +534,36 @@ export class Highlight {
}
}

// Keyframes that bring the cursor from (dx, dy) relative to its destination to the destination.
// The path bows to one side, more at the start than at the end, the way a hand moves a mouse.
function cursorPathKeyframes(dx: number, dy: number, seed: number): Keyframe[] {
const random = (salt: number) => {
const value = Math.sin(seed * 12.9898 + salt * 78.233) * 43758.5453;
return value - Math.floor(value);
};
const distance = Math.hypot(dx, dy);
const normalX = -dy / distance;
const normalY = dx / distance;
const side = random(1) < 0.5 ? -1 : 1;
const startBow = side * distance * (0.03 + 0.03 * random(2));
const endBow = side * distance * (0.01 + 0.02 * random(3));
// Control points at one and two thirds of the way.
const c1x = dx * 2 / 3 + normalX * startBow;
const c1y = dy * 2 / 3 + normalY * startBow;
const c2x = dx / 3 + normalX * endBow;
const c2y = dy / 3 + normalY * endBow;
const keyframes: Keyframe[] = [];
const steps = 30;
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const u = 1 - t;
const px = u * u * u * dx + 3 * u * u * t * c1x + 3 * u * t * t * c2x;
const py = u * u * u * dy + 3 * u * u * t * c1y + 3 * u * t * t * c2y;
keyframes.push({ transform: `translate(${px}px, ${py}px)` });
}
return keyframes;
}

function toDOMRect(box: Rect): DOMRect;
function toDOMRect(box: Rect | undefined): DOMRect | undefined;
function toDOMRect(box: Rect | undefined): DOMRect | undefined {
Expand Down
36 changes: 21 additions & 15 deletions packages/injected/src/injectedScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1351,32 +1351,38 @@ export class InjectedScript {
highlight.setElementHighlights(highlights);
}

setScreencastAnnotation(annotation: { point?: Point, box?: Rect, actionTitle?: string, duration?: number, position?: string, fontSize?: number, cursor?: 'none' | 'pointer' } | null) {
setScreencastAnnotation(annotation: { point?: Point, box?: Rect, actionTitle?: string, duration?: number, position?: string, cursor?: 'none' | 'pointer', style?: { point?: string, highlight?: string, title?: string }, lastCursorPoint?: Point } | null) {
const highlight = this._ensureHighlight();
highlight.hideScreencastDecorations();
// The cursor outlives the annotation, it stays at the last action point.
if (!annotation) {
highlight.updateHighlight([]);
highlight.hideActionPoint();
highlight.hideActionTitle();
highlight.hideActionCursor();
return;
}
const fadeDuration = annotation.duration ?? 500;

if (annotation.box) {
highlight.updateHighlight([{
box: annotation.box,
color: 'rgba(0, 128, 255, 0.15)',
borderColor: 'rgba(0, 128, 255, 0.6)',
fadeDuration,
}]);
}
if (annotation.box && annotation.style?.highlight)
highlight.showScreencastHighlight(annotation.box, annotation.style.highlight, fadeDuration);
if (annotation.point) {
if (annotation.cursor !== 'none')
if (annotation.cursor !== 'none') {
// After navigation, animate from where the cursor was in the previous document.
if (annotation.lastCursorPoint)
highlight.restoreActionCursor(annotation.lastCursorPoint.x, annotation.lastCursorPoint.y);
highlight.moveActionCursor(annotation.point.x, annotation.point.y, fadeDuration);
highlight.showActionPoint(annotation.point.x, annotation.point.y, fadeDuration);
}
if (annotation.style?.point)
highlight.showScreencastPoint(annotation.point.x, annotation.point.y, annotation.style.point, fadeDuration);
}
if (annotation.actionTitle)
highlight.showActionTitle(annotation.actionTitle, fadeDuration, annotation.position, annotation.fontSize);
highlight.showActionTitle(annotation.actionTitle, fadeDuration, annotation.position, annotation.style?.title);
}

restoreScreencastCursor(point: Point) {
this._ensureHighlight().restoreActionCursor(point.x, point.y);
}

hideScreencastCursor() {
this._highlight?.hideActionCursor();
}

addUserOverlay(id: string, html: string) {
Expand Down
Loading
Loading