fix: move preview selection badges off small targets

Preview annotations should keep the selected element readable. The selected rectangle now carries a stronger translucent blue fill, while the numbered badge is a separate high-contrast fixed overlay placed outside the selected rect and clamped to the viewport.

Constraint: No issue number was provided for this task.

Constraint: Preserve the DOM-captured annotation path introduced for viewport-safe screenshots.

Rejected: Put the badge inside the selected box | it obscures compact buttons and labels.

Rejected: Return to canvas-side badge drawing | that reintroduces the separate coordinate system.

Confidence: high

Scope-risk: narrow

Directive: Keep numbered badges outside compact selected targets unless visual evidence shows a better non-obscuring placement.

Tested: cd desktop && bun run test --run src/preview-agent/screenshot.test.ts src/preview-agent/picker.test.ts src/preview-agent/metadata.test.ts src/preview-agent/protocol.test.ts src/preview-agent/bridge.test.ts src/preview-agent/editBubble.test.ts

Tested: cd desktop && bun run lint

Tested: cd desktop && bun run build:preview-agent

Tested: bun run check:desktop

Tested: git diff --check

Not-tested: Real Tauri webview visual smoke.
This commit is contained in:
程序员阿江(Relakkes) 2026-06-01 02:19:13 +08:00
parent c2feac8220
commit fd040e3798
3 changed files with 83 additions and 21 deletions

File diff suppressed because one or more lines are too long

View File

@ -151,13 +151,15 @@ describe('captureAnnotatedRegion', () => {
const ctx = makeMockCtx()
html2canvasMock.mockImplementation(async () => {
const overlay = document.querySelector('[data-preview-selection-annotation="true"]') as HTMLElement | null
const badge = document.querySelector('[data-preview-selection-badge="true"]') as HTMLElement | null
expect(overlay).not.toBeNull()
expect(badge).not.toBeNull()
expect(overlay?.style.position).toBe('fixed')
expect(overlay?.style.left).toBe('900px')
expect(overlay?.style.top).toBe('320px')
expect(overlay?.style.width).toBe('86px')
expect(overlay?.style.height).toBe('48px')
expect(overlay?.textContent).toContain('1')
expect(badge?.textContent).toContain('1')
return {
getContext: () => ctx as unknown as CanvasRenderingContext2D,
toDataURL: () => 'data:image/png;base64,RAW',
@ -175,7 +177,35 @@ describe('captureAnnotatedRegion', () => {
await captureAnnotatedRegion(el, 1)
expect(ctx.arc).not.toHaveBeenCalled()
expect(document.querySelector('[data-preview-selection-annotation="true"]')).toBeNull()
expect(document.querySelector('[data-preview-selection-annotation-root="true"]')).toBeNull()
})
it('places the numbered badge outside the selected element so small buttons stay readable', async () => {
html2canvasMock.mockImplementation(async () => {
const overlay = document.querySelector('[data-preview-selection-annotation="true"]') as HTMLElement | null
const badge = document.querySelector('[data-preview-selection-badge="true"]') as HTMLElement | null
expect(overlay).not.toBeNull()
expect(badge).not.toBeNull()
expect(badge?.style.position).toBe('fixed')
expect(Number.parseFloat(badge?.style.top ?? '0') + 26).toBeLessThanOrEqual(320)
expect(badge?.style.color).toBe('white')
expect(badge?.style.background).toBe('rgb(47, 123, 255)')
expect(overlay?.textContent).not.toContain('1')
return {
getContext: () => makeMockCtx() as unknown as CanvasRenderingContext2D,
toDataURL: () => 'data:image/png;base64,RAW',
}
})
const el = document.createElement('button')
vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
left: 900, top: 320, width: 86, height: 48,
right: 986, bottom: 368, x: 900, y: 320,
toJSON: () => ({}),
} as DOMRect)
await captureAnnotatedRegion(el, 1)
expect(document.querySelector('[data-preview-selection-badge="true"]')).toBeNull()
})
it('returns the compressed wrapper of the canvas dataURL', async () => {

View File

@ -19,11 +19,39 @@ function setImportant(style: CSSStyleDeclaration, property: string, value: strin
style.setProperty(property, value, 'important')
}
const BADGE_SIZE = 26
const BADGE_GAP = 4
const VIEWPORT_MARGIN = 4
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(value, max))
}
function computeBadgePosition(rect: DOMRect): { left: number; top: number } {
const preferredLeft = rect.left + rect.width / 2 - BADGE_SIZE / 2
const maxLeft = Math.max(VIEWPORT_MARGIN, window.innerWidth - BADGE_SIZE - VIEWPORT_MARGIN)
const left = clamp(preferredLeft, VIEWPORT_MARGIN, maxLeft)
const topAbove = rect.top - BADGE_SIZE - BADGE_GAP
const topBelow = rect.bottom + BADGE_GAP
if (topAbove >= VIEWPORT_MARGIN) return { left, top: topAbove }
if (topBelow + BADGE_SIZE <= window.innerHeight - VIEWPORT_MARGIN) return { left, top: topBelow }
return { left, top: clamp(topAbove, VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, window.innerHeight - BADGE_SIZE - VIEWPORT_MARGIN)) }
}
function createAnnotationOverlay(el: Element, label: number | string): HTMLElement {
const rect = el.getBoundingClientRect()
const root = document.createElement('div')
root.dataset.previewSelectionAnnotationRoot = 'true'
root.setAttribute('aria-hidden', 'true')
setImportant(root.style, 'position', 'fixed')
setImportant(root.style, 'inset', '0')
setImportant(root.style, 'overflow', 'visible')
setImportant(root.style, 'pointer-events', 'none')
setImportant(root.style, 'z-index', '2147483647')
const overlay = document.createElement('div')
overlay.dataset.previewSelectionAnnotation = 'true'
overlay.setAttribute('aria-hidden', 'true')
setImportant(overlay.style, 'position', 'fixed')
setImportant(overlay.style, 'left', `${rect.left}px`)
setImportant(overlay.style, 'top', `${rect.top}px`)
@ -32,31 +60,35 @@ function createAnnotationOverlay(el: Element, label: number | string): HTMLEleme
setImportant(overlay.style, 'box-sizing', 'border-box')
setImportant(overlay.style, 'border', '3px solid #2f7bff')
setImportant(overlay.style, 'border-radius', '8px')
setImportant(overlay.style, 'background', 'rgba(47, 123, 255, 0.08)')
setImportant(overlay.style, 'background', 'rgba(47, 123, 255, 0.16)')
setImportant(overlay.style, 'box-shadow', '0 0 0 2px rgba(255, 255, 255, 0.9)')
setImportant(overlay.style, 'pointer-events', 'none')
setImportant(overlay.style, 'z-index', '2147483647')
const badge = document.createElement('div')
badge.dataset.previewSelectionBadge = 'true'
badge.textContent = String(label)
setImportant(badge.style, 'position', 'absolute')
setImportant(badge.style, 'top', '4px')
setImportant(badge.style, 'right', '4px')
const badgePos = computeBadgePosition(rect)
setImportant(badge.style, 'position', 'fixed')
setImportant(badge.style, 'left', `${badgePos.left}px`)
setImportant(badge.style, 'top', `${badgePos.top}px`)
setImportant(badge.style, 'display', 'flex')
setImportant(badge.style, 'align-items', 'center')
setImportant(badge.style, 'justify-content', 'center')
setImportant(badge.style, 'width', '24px')
setImportant(badge.style, 'height', '24px')
setImportant(badge.style, 'width', `${BADGE_SIZE}px`)
setImportant(badge.style, 'height', `${BADGE_SIZE}px`)
setImportant(badge.style, 'border-radius', '999px')
setImportant(badge.style, 'background', '#2f7bff')
setImportant(badge.style, 'color', '#ffffff')
setImportant(badge.style, 'border', '2px solid #ffffff')
setImportant(badge.style, 'color', 'white')
setImportant(badge.style, 'font', '700 14px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif')
setImportant(badge.style, 'line-height', '24px')
setImportant(badge.style, 'box-shadow', '0 0 0 2px rgba(255, 255, 255, 0.95)')
overlay.appendChild(badge)
setImportant(badge.style, 'line-height', `${BADGE_SIZE}px`)
setImportant(badge.style, 'box-sizing', 'border-box')
setImportant(badge.style, 'box-shadow', '0 1px 8px rgba(47, 123, 255, 0.28), 0 0 0 1px rgba(47, 123, 255, 0.22)')
document.documentElement.appendChild(overlay)
return overlay
root.appendChild(overlay)
root.appendChild(badge)
document.documentElement.appendChild(root)
return root
}
/** Viewport screenshot with the picked element's region annotated (blue box + numbered badge). 图4 */