diff --git a/docs/src/api/class-browsercontext.md b/docs/src/api/class-browsercontext.md index e8099c2999340..27ded1efe6796 100644 --- a/docs/src/api/class-browsercontext.md +++ b/docs/src/api/class-browsercontext.md @@ -68,11 +68,11 @@ await context.CloseAsync(); This event is not emitted. -## event: BrowserContext.bringToFront +## event: BrowserContext.pickLocator * since: v1.60 - argument: <[Page]> -Emitted when a client calls [`method: Page.bringToFront`] on a page in this context. The event is dispatched to all +Emitted when a client calls [`method: Page.pickLocator`] on a page in this context. The event is dispatched to all clients connected to the context, including the one that initiated the call. ## property: BrowserContext.clock diff --git a/eslint.config.mjs b/eslint.config.mjs index 9ac28172f4531..df150e14cd24c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -286,6 +286,8 @@ const reactFiles = [ `packages/recorder/src/**/*.tsx`, `packages/trace-viewer/src/**/*.ts`, `packages/trace-viewer/src/**/*.tsx`, + `packages/dashboard/src/**/*.ts`, + `packages/dashboard/src/**/*.tsx`, `packages/web/src/**/*.ts`, `packages/web/src/**/*.tsx`, ]; diff --git a/package-lock.json b/package-lock.json index 154f1bdb5dcb4..6130a56acd854 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9793,7 +9793,8 @@ "dependencies": { "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", - "codemirror": "5.65.18" + "codemirror": "5.65.18", + "yaml": "^2.8.3" } } } diff --git a/packages/dashboard/src/annotations.css b/packages/dashboard/src/annotations.css new file mode 100644 index 0000000000000..0090d8e3d9e4a --- /dev/null +++ b/packages/dashboard/src/annotations.css @@ -0,0 +1,255 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.annotation-layer { + position: absolute; + inset: 0; + z-index: 5; + outline: none; + cursor: crosshair; +} + +.annotation-layer::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + box-shadow: inset 0 0 0 1px rgb(var(--annotate-blue) / 0.72), inset 0 0 26px rgb(var(--annotate-blue) / 0.28); +} + +.annotation-toolbar { + position: absolute; + top: 8px; + right: 8px; + display: inline-flex; + gap: 6px; + padding: 6px; + background: var(--color-canvas-overlay); + border: 1px solid var(--color-border-muted); + border-radius: 999px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + z-index: 20; + cursor: default; +} + +.annotate-action-btn { + height: 26px; + padding: 0 12px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; + background: var(--color-neutral-subtle); + color: var(--color-fg-default); + border: 1px solid var(--color-border-muted); + border-radius: 999px; + cursor: pointer; + line-height: 1; +} + +.annotate-action-btn:hover:not(:disabled) { + background: var(--color-neutral-muted); +} + +.annotate-action-btn:disabled { + opacity: 0.5; + cursor: default; +} + +.annotate-action-btn.primary { + background: rgb(var(--annotate-blue)); + color: #fff; + border-color: transparent; +} + +.annotate-action-btn.primary:hover:not(:disabled) { + background: rgb(var(--annotate-blue) / 0.85); +} + +.annotate-action-btn.danger { + background: transparent; + color: var(--color-danger-fg); + border-color: var(--color-border-muted); +} + +.annotate-action-btn.danger:hover:not(:disabled) { + background: var(--color-danger-subtle); +} + +.annotation-rect { + position: absolute; + box-sizing: border-box; + border: 2px solid rgb(var(--annotate-blue)); + background: rgb(var(--annotate-blue) / 0.12); + cursor: pointer; + transition: box-shadow 120ms ease; +} + +.annotation-rect:hover { + box-shadow: 0 0 0 3px rgb(var(--annotate-blue) / 0.25); +} + +.annotation-rect.selected { + box-shadow: 0 0 0 3px rgb(var(--annotate-blue) / 0.4); + border-style: solid; + border-color: rgb(var(--annotate-blue)); + cursor: move; +} + +.annotation-rect.draft { + border-style: dashed; + background: rgb(var(--annotate-blue) / 0.18); + pointer-events: none; +} + +.annotation-handle { + position: absolute; + width: 10px; + height: 10px; + background: #fff; + border: 1.5px solid rgb(var(--annotate-blue)); + border-radius: 2px; + box-sizing: border-box; + z-index: 1; +} + +.annotation-handle-nw { left: -6px; top: -6px; cursor: nwse-resize; } +.annotation-handle-n { left: 50%; top: -6px; margin-left: -5px; cursor: ns-resize; } +.annotation-handle-ne { right: -6px; top: -6px; cursor: nesw-resize; } +.annotation-handle-e { right: -6px; top: 50%; margin-top: -5px; cursor: ew-resize; } +.annotation-handle-se { right: -6px; bottom: -6px; cursor: nwse-resize; } +.annotation-handle-s { left: 50%; bottom: -6px; margin-left: -5px; cursor: ns-resize; } +.annotation-handle-sw { left: -6px; bottom: -6px; cursor: nesw-resize; } +.annotation-handle-w { left: -6px; top: 50%; margin-top: -5px; cursor: ew-resize; } + +.annotation-label { + position: absolute; + top: -2px; + left: -2px; + max-width: calc(100% + 4px); + padding: 2px 6px; + background: rgb(var(--annotate-blue)); + color: #fff; + font-size: 11px; + font-weight: 500; + border-radius: 2px 2px 2px 0; + transform: translateY(-100%); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: text; +} + +.annotation-label:hover { + background: rgb(var(--annotate-blue) / 0.85); +} + +.annotation-popover { + position: absolute; + min-width: 220px; + max-width: 320px; + background: var(--color-canvas-overlay); + border: 1px solid var(--color-border-default); + border-radius: 8px; + padding: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); + display: flex; + flex-direction: column; + gap: 6px; + z-index: 10; + cursor: default; +} + +.annotation-textarea { + width: 100%; + min-height: 64px; + resize: vertical; + padding: 6px 8px; + font-family: inherit; + font-size: 12px; + color: var(--color-fg-default); + background: var(--color-canvas-default); + border: 1px solid var(--color-border-muted); + border-radius: 6px; + outline: none; + box-sizing: border-box; +} + +.annotation-textarea:focus { + border-color: rgb(var(--annotate-blue)); +} + +.annotation-popover-actions { + display: flex; + gap: 6px; + justify-content: flex-end; +} + +.annotation-modal-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + cursor: default; +} + +.annotation-modal { + width: min(520px, 92%); + max-height: 80%; + background: var(--color-canvas-default); + border: 1px solid var(--color-border-default); + border-radius: 10px; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.annotation-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--color-border-muted); + font-size: 13px; + font-weight: 600; + color: var(--color-fg-default); +} + +.annotation-modal-text { + flex: 1; + min-height: 200px; + padding: 12px 16px; + font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace; + font-size: 12px; + color: var(--color-fg-default); + background: var(--color-canvas-subtle); + border: none; + outline: none; + resize: none; + white-space: pre; +} + +.annotation-modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 10px 16px; + border-top: 1px solid var(--color-border-muted); +} diff --git a/packages/dashboard/src/annotations.tsx b/packages/dashboard/src/annotations.tsx new file mode 100644 index 0000000000000..628a1bc736447 --- /dev/null +++ b/packages/dashboard/src/annotations.tsx @@ -0,0 +1,458 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import './annotations.css'; + +export type Annotation = { id: string; x: number; y: number; width: number; height: number; text: string }; + +type Rect = { x: number; y: number; width: number; height: number }; +type DragKind = 'move' | 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w'; +const HANDLES: Exclude[] = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']; + +type DragState = { + kind: DragKind; + id: string; + orig: Rect; + startVx: number; + startVy: number; +}; + +type Selection = { id: string; editing: boolean } | null; + +const MIN_ANNOTATION_SIZE = 4; + +function newAnnotationId() { + return 'ann-' + Math.random().toString(36).slice(2, 10); +} + +function normalizeRect(a: { startX: number; startY: number; x: number; y: number }): Rect { + return { + x: Math.min(a.startX, a.x), + y: Math.min(a.startY, a.y), + width: Math.abs(a.x - a.startX), + height: Math.abs(a.y - a.startY), + }; +} + +function applyDrag(orig: Rect, kind: DragKind, dvx: number, dvy: number): Rect { + let left = orig.x; + let top = orig.y; + let right = orig.x + orig.width; + let bottom = orig.y + orig.height; + switch (kind) { + case 'move': + left += dvx; top += dvy; right += dvx; bottom += dvy; break; + case 'nw': left += dvx; top += dvy; break; + case 'n': top += dvy; break; + case 'ne': right += dvx; top += dvy; break; + case 'e': right += dvx; break; + case 'se': right += dvx; bottom += dvy; break; + case 's': bottom += dvy; break; + case 'sw': left += dvx; bottom += dvy; break; + case 'w': left += dvx; break; + } + return { + x: Math.min(left, right), + y: Math.min(top, bottom), + width: Math.abs(right - left), + height: Math.abs(bottom - top), + }; +} + +export type ImageLayout = { + rect: DOMRect; + renderW: number; + renderH: number; + offsetX: number; + offsetY: number; +}; + +export function getImageLayout(display: HTMLImageElement | null): ImageLayout | null { + if (!display || !display.naturalWidth || !display.naturalHeight) + return null; + const rect = display.getBoundingClientRect(); + const imgAspect = display.naturalWidth / display.naturalHeight; + const elemAspect = rect.width / rect.height; + if (imgAspect > elemAspect) { + const renderH = rect.width / imgAspect; + return { rect, renderW: rect.width, renderH, offsetX: 0, offsetY: (rect.height - renderH) / 2 }; + } + const renderW = rect.height * imgAspect; + return { rect, renderW, renderH: rect.height, offsetX: (rect.width - renderW) / 2, offsetY: 0 }; +} + +export function clientToViewport(layout: ImageLayout, vw: number, vh: number, clientX: number, clientY: number): { x: number; y: number } { + const fracX = (clientX - layout.rect.left - layout.offsetX) / layout.renderW; + const fracY = (clientY - layout.rect.top - layout.offsetY) / layout.renderH; + return { x: Math.round(fracX * vw), y: Math.round(fracY * vh) }; +} + +function viewportRectToScreenStyle(layout: ImageLayout, screenRect: DOMRect, vw: number, vh: number, r: Rect): React.CSSProperties { + const baseLeft = layout.rect.left - screenRect.left + layout.offsetX; + const baseTop = layout.rect.top - screenRect.top + layout.offsetY; + return { + left: baseLeft + (r.x / vw) * layout.renderW, + top: baseTop + (r.y / vh) * layout.renderH, + width: (r.width / vw) * layout.renderW, + height: (r.height / vh) * layout.renderH, + }; +} + +export const Annotations: React.FC<{ + active: boolean; + displayRef: React.RefObject; + screenRef: React.RefObject; + viewportWidth: number; + viewportHeight: number; +}> = ({ active, displayRef, screenRef, viewportWidth, viewportHeight }) => { + const [annotations, setAnnotations] = React.useState([]); + const [draft, setDraft] = React.useState<{ startX: number; startY: number; x: number; y: number } | null>(null); + const [selection, setSelection] = React.useState(null); + const [drag, setDrag] = React.useState(null); + const [submittedJson, setSubmittedJson] = React.useState(null); + const [, setTick] = React.useState(0); + const forceRender = React.useCallback(() => setTick(t => t + 1), []); + const layerRef = React.useRef(null); + + const selectedId = selection?.id ?? null; + const editingId = selection?.editing ? selection.id : null; + + React.useEffect(() => { + if (!active) + return; + const onResize = () => forceRender(); + const img = displayRef.current; + const onLoad = () => forceRender(); + window.addEventListener('resize', onResize); + img?.addEventListener('load', onLoad); + return () => { + window.removeEventListener('resize', onResize); + img?.removeEventListener('load', onLoad); + }; + }, [active, displayRef, forceRender]); + + React.useEffect(() => { + if (active) + layerRef.current?.focus(); + else + setSelection(null); + }, [active]); + + React.useEffect(() => { + if (!drag) + return; + const onMove = (e: MouseEvent) => { + const layout = getImageLayout(displayRef.current); + if (!layout || !viewportWidth || !viewportHeight) + return; + const vp = clientToViewport(layout, viewportWidth, viewportHeight, e.clientX, e.clientY); + const dvx = vp.x - drag.startVx; + const dvy = vp.y - drag.startVy; + if (dvx === 0 && dvy === 0) + return; + setAnnotations(prev => prev.map(a => a.id === drag.id ? { ...a, ...applyDrag(drag.orig, drag.kind, dvx, dvy) } : a)); + }; + const onUp = () => setDrag(null); + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + return () => { + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + }; + }, [drag, displayRef, viewportWidth, viewportHeight]); + + function imgCoords(e: React.MouseEvent): { x: number; y: number } | null { + if (!viewportWidth || !viewportHeight) + return null; + const layout = getImageLayout(displayRef.current); + if (!layout) + return null; + return clientToViewport(layout, viewportWidth, viewportHeight, e.clientX, e.clientY); + } + + function hitTestAnnotation(x: number, y: number): Annotation | undefined { + for (let i = annotations.length - 1; i >= 0; i--) { + const a = annotations[i]; + if (x >= a.x && x <= a.x + a.width && y >= a.y && y <= a.y + a.height) + return a; + } + return undefined; + } + + function onLayerMouseDown(e: React.MouseEvent) { + if (e.button !== 0) + return; + layerRef.current?.focus(); + const vp = imgCoords(e); + if (!vp) + return; + const hit = hitTestAnnotation(vp.x, vp.y); + e.preventDefault(); + if (hit) { + setSelection({ id: hit.id, editing: false }); + setDrag({ kind: 'move', id: hit.id, orig: { x: hit.x, y: hit.y, width: hit.width, height: hit.height }, startVx: vp.x, startVy: vp.y }); + return; + } + setDraft({ startX: vp.x, startY: vp.y, x: vp.x, y: vp.y }); + setSelection(null); + } + + function onLayerMouseMove(e: React.MouseEvent) { + if (drag || !draft) + return; + const vp = imgCoords(e); + if (!vp) + return; + if (draft.x === vp.x && draft.y === vp.y) + return; + setDraft({ ...draft, x: vp.x, y: vp.y }); + } + + function onLayerMouseUp(e: React.MouseEvent) { + if (!draft) + return; + e.preventDefault(); + const rect = normalizeRect(draft); + setDraft(null); + if (rect.width < MIN_ANNOTATION_SIZE || rect.height < MIN_ANNOTATION_SIZE) + return; + const id = newAnnotationId(); + setAnnotations(prev => [...prev, { id, ...rect, text: '' }]); + setSelection({ id, editing: true }); + } + + function startResize(kind: Exclude, a: Annotation, e: React.MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + layerRef.current?.focus(); + const vp = imgCoords(e); + if (!vp) + return; + setSelection({ id: a.id, editing: false }); + setDrag({ kind, id: a.id, orig: { x: a.x, y: a.y, width: a.width, height: a.height }, startVx: vp.x, startVy: vp.y }); + } + + function nudgeSelected(dx: number, dy: number) { + if (!selectedId) + return; + setAnnotations(prev => prev.map(a => a.id === selectedId ? { ...a, x: a.x + dx, y: a.y + dy } : a)); + } + + function closeEditor() { + setSelection(sel => sel?.editing ? { ...sel, editing: false } : sel); + layerRef.current?.focus(); + } + + function onLayerKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Escape') { + e.preventDefault(); + if (draft) + setDraft(null); + else if (editingId) + closeEditor(); + else if (selectedId) + setSelection(null); + return; + } + if ((e.key === 'Delete' || e.key === 'Backspace') && selectedId && !editingId) { + e.preventDefault(); + setAnnotations(prev => prev.filter(a => a.id !== selectedId)); + setSelection(null); + return; + } + if (selectedId && !editingId) { + const step = e.shiftKey ? 1 : 5; + if (e.key === 'ArrowLeft') { e.preventDefault(); nudgeSelected(-step, 0); return; } + if (e.key === 'ArrowRight') { e.preventDefault(); nudgeSelected(step, 0); return; } + if (e.key === 'ArrowUp') { e.preventDefault(); nudgeSelected(0, -step); return; } + if (e.key === 'ArrowDown') { e.preventDefault(); nudgeSelected(0, step); return; } + } + } + + function submitAnnotations() { + const json = JSON.stringify({ viewportWidth, viewportHeight, annotations }, null, 2); + console.log('Annotations:', json); + setSubmittedJson(json); + } + + if (!active) + return null; + + const layout = getImageLayout(displayRef.current); + const screenRect = screenRef.current?.getBoundingClientRect() ?? null; + const mapRect = (r: Rect): React.CSSProperties | null => + (layout && screenRect && viewportWidth && viewportHeight) + ? viewportRectToScreenStyle(layout, screenRect, viewportWidth, viewportHeight, r) + : null; + + const editingAnnotation = editingId ? annotations.find(a => a.id === editingId) : undefined; + + return ( +
e.preventDefault()} + > +
e.stopPropagation()}> + + +
+ + {annotations.map(a => { + const style = mapRect(a); + if (!style) + return null; + const isSelected = a.id === selectedId; + const isEditing = a.id === editingId; + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + setSelection({ id: a.id, editing: true }); + }} + > + {a.text && ( +
{ + e.preventDefault(); + e.stopPropagation(); + setSelection({ id: a.id, editing: true }); + }} + > + {a.text} +
+ )} + {isSelected && !isEditing && HANDLES.map(h => ( +
startResize(h, a, e)} + /> + ))} +
+ ); + })} + + {draft && (() => { + const style = mapRect(normalizeRect(draft)); + return style ?
: null; + })()} + + {editingAnnotation && (() => { + const style = mapRect(editingAnnotation); + if (!style) + return null; + const popoverStyle: React.CSSProperties = { + left: style.left as number, + top: (style.top as number) + (style.height as number) + 6, + }; + return ( +
e.stopPropagation()} + onClick={e => e.stopPropagation()} + > +