mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
fix: keep preview selection markers in the captured viewport
Selection screenshots were drawing the marker after html2canvas finished by mapping DOM coordinates onto a separate canvas. That leaves room for drift when html2canvas renders at a different output size, the viewport is resized, or the thumbnail is compressed. The marker is now a fixed DOM overlay captured with the page itself, so the chosen element and its marker share the same viewport, layout, crop, and scale path. Constraint: No issue number was provided for this task. Constraint: Preserve the existing selection event payload and preview bridge protocol. Rejected: Keep canvas post-processing with more scale math | it still maintains a second coordinate system beside the rendered page. Rejected: Keep the unused annotate module | it would preserve the stale path that caused this class of drift. Confidence: high Scope-risk: narrow Directive: Do not reintroduce canvas-side selection marker math unless it is proven against resized and scaled viewport captures. 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:
parent
7ca702a253
commit
a09d9aa009
File diff suppressed because one or more lines are too long
@ -1,87 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { computeAnnotationRect, drawAnnotation } from './annotate'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// computeAnnotationRect
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('computeAnnotationRect', () => {
|
||||
it('(a) maps element rect relative to body rect at scale 1', () => {
|
||||
const box = computeAnnotationRect(
|
||||
{ left: 100, top: 50, width: 80, height: 40 },
|
||||
{ left: 0, top: 0, width: 1000, height: 2000 },
|
||||
1,
|
||||
)
|
||||
expect(box).toEqual({ x: 100, y: 50, w: 80, h: 40 })
|
||||
})
|
||||
|
||||
it('(b) cancels scroll — scrolled body with top:-200 gives correct page-offset y', () => {
|
||||
// body rect top = -200 means page has scrolled 200px down
|
||||
const box = computeAnnotationRect(
|
||||
{ left: 100, top: 50, width: 80, height: 40 },
|
||||
{ left: 0, top: -200, width: 1000, height: 2000 },
|
||||
1,
|
||||
)
|
||||
expect(box).toEqual({ x: 100, y: 250, w: 80, h: 40 })
|
||||
})
|
||||
|
||||
it('(c) scale 2 doubles all four values', () => {
|
||||
const box = computeAnnotationRect(
|
||||
{ left: 100, top: 50, width: 80, height: 40 },
|
||||
{ left: 0, top: 0, width: 1000, height: 2000 },
|
||||
2,
|
||||
)
|
||||
expect(box).toEqual({ x: 200, y: 100, w: 160, h: 80 })
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// drawAnnotation — mock 2d context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeMockCtx() {
|
||||
return {
|
||||
// mutable props
|
||||
lineWidth: 0,
|
||||
strokeStyle: '',
|
||||
fillStyle: '',
|
||||
font: '',
|
||||
textAlign: '' as CanvasRenderingContext2D['textAlign'],
|
||||
textBaseline: '' as CanvasRenderingContext2D['textBaseline'],
|
||||
// mock functions
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
arcTo: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
closePath: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('drawAnnotation', () => {
|
||||
it('draws the selection outline and badge with expected calls', () => {
|
||||
const ctx = makeMockCtx()
|
||||
drawAnnotation(ctx as unknown as CanvasRenderingContext2D, { x: 10, y: 20, w: 30, h: 40 }, 1)
|
||||
|
||||
// selection outline must be stroked
|
||||
expect(ctx.stroke).toHaveBeenCalled()
|
||||
|
||||
// badge: arc(cx, cy, radius, ...) — cx = x + w/2 = 10 + 15 = 25, cy = y = 20, radius = 13
|
||||
expect(ctx.arc).toHaveBeenCalled()
|
||||
const arcCall = ctx.arc.mock.calls[0]
|
||||
expect(arcCall?.[0]).toBe(25) // cx
|
||||
expect(arcCall?.[1]).toBe(20) // cy
|
||||
expect(arcCall?.[2]).toBe(13) // radius
|
||||
|
||||
// fillText('1', cx, cy)
|
||||
expect(ctx.fillText).toHaveBeenCalled()
|
||||
const textCall = ctx.fillText.mock.calls[0]
|
||||
expect(textCall?.[0]).toBe('1')
|
||||
expect(textCall?.[1]).toBe(25)
|
||||
expect(textCall?.[2]).toBe(20)
|
||||
})
|
||||
})
|
||||
@ -1,51 +0,0 @@
|
||||
export type Box = { x: number; y: number; w: number; h: number }
|
||||
export type RectLike = { left: number; top: number; width: number; height: number }
|
||||
|
||||
/**
|
||||
* Map an element's viewport rect into the html2canvas full-body capture's pixel coords.
|
||||
* Both rects are viewport-relative, so subtracting the body's rect yields the element's
|
||||
* offset within the captured body (scroll cancels out). Multiply by the capture scale.
|
||||
*/
|
||||
export function computeAnnotationRect(elementRect: RectLike, bodyRect: RectLike, scale = 1): Box {
|
||||
return {
|
||||
x: (elementRect.left - bodyRect.left) * scale,
|
||||
y: (elementRect.top - bodyRect.top) * scale,
|
||||
w: elementRect.width * scale,
|
||||
h: elementRect.height * scale,
|
||||
}
|
||||
}
|
||||
|
||||
const ACCENT = '#2f7bff'
|
||||
|
||||
function roundRectPath(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
|
||||
const rr = Math.max(0, Math.min(r, w / 2, h / 2))
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x + rr, y)
|
||||
ctx.arcTo(x + w, y, x + w, y + h, rr)
|
||||
ctx.arcTo(x + w, y + h, x, y + h, rr)
|
||||
ctx.arcTo(x, y + h, x, y, rr)
|
||||
ctx.arcTo(x, y, x + w, y, rr)
|
||||
ctx.closePath()
|
||||
}
|
||||
|
||||
/** Draw a blue rounded selection box + a numbered badge centered on the top edge (图4). */
|
||||
export function drawAnnotation(ctx: CanvasRenderingContext2D, box: Box, label: number | string = 1, scale = 1): void {
|
||||
ctx.save()
|
||||
ctx.lineWidth = Math.max(2, Math.round(3 * scale))
|
||||
ctx.strokeStyle = ACCENT
|
||||
roundRectPath(ctx, box.x, box.y, box.w, box.h, 6 * scale)
|
||||
ctx.stroke()
|
||||
const cx = box.x + box.w / 2
|
||||
const cy = box.y
|
||||
const radius = 13 * scale
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2)
|
||||
ctx.fillStyle = ACCENT
|
||||
ctx.fill()
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.font = `bold ${Math.round(14 * scale)}px sans-serif`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText(String(label), cx, cy)
|
||||
ctx.restore()
|
||||
}
|
||||
@ -83,7 +83,7 @@ describe('captureAnnotatedRegion', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
it('draws the annotation on the captured canvas (stroke and fillText called)', async () => {
|
||||
it('keeps the annotation inside the DOM capture instead of post-processing canvas pixels', async () => {
|
||||
const ctx = makeMockCtx()
|
||||
html2canvasMock.mockResolvedValue({
|
||||
getContext: () => ctx as unknown as CanvasRenderingContext2D,
|
||||
@ -100,17 +100,24 @@ describe('captureAnnotatedRegion', () => {
|
||||
|
||||
await captureAnnotatedRegion(el, 1)
|
||||
|
||||
expect(ctx.stroke).toHaveBeenCalled()
|
||||
expect(ctx.fillText).toHaveBeenCalled()
|
||||
expect(ctx.stroke).not.toHaveBeenCalled()
|
||||
expect(ctx.fillText).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('draws the selected element at viewport coordinates in the captured viewport', async () => {
|
||||
it('places the selected element overlay at viewport coordinates in the captured viewport', async () => {
|
||||
const ctx = makeMockCtx()
|
||||
html2canvasMock.mockResolvedValue({
|
||||
getContext: () => ctx as unknown as CanvasRenderingContext2D,
|
||||
toDataURL: () => 'data:image/png;base64,RAW',
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
html2canvasMock.mockImplementation(async () => {
|
||||
const overlay = document.querySelector('[data-preview-selection-annotation="true"]') as HTMLElement | null
|
||||
expect(overlay?.style.left).toBe('100px')
|
||||
expect(overlay?.style.top).toBe('50px')
|
||||
expect(overlay?.style.width).toBe('80px')
|
||||
expect(overlay?.style.height).toBe('40px')
|
||||
return {
|
||||
getContext: () => ctx as unknown as CanvasRenderingContext2D,
|
||||
toDataURL: () => 'data:image/png;base64,RAW',
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
}
|
||||
})
|
||||
const el = document.createElement('input')
|
||||
vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
|
||||
@ -137,9 +144,38 @@ describe('captureAnnotatedRegion', () => {
|
||||
windowHeight: window.innerHeight,
|
||||
scale: 1,
|
||||
})
|
||||
const badgeCall = ctx.arc.mock.calls[0]
|
||||
expect(badgeCall?.[0]).toBe(140)
|
||||
expect(badgeCall?.[1]).toBe(50)
|
||||
expect(ctx.arc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('captures the annotation as a viewport DOM overlay so output canvas scaling cannot drift', async () => {
|
||||
const ctx = makeMockCtx()
|
||||
html2canvasMock.mockImplementation(async () => {
|
||||
const overlay = document.querySelector('[data-preview-selection-annotation="true"]') as HTMLElement | null
|
||||
expect(overlay).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')
|
||||
return {
|
||||
getContext: () => ctx as unknown as CanvasRenderingContext2D,
|
||||
toDataURL: () => 'data:image/png;base64,RAW',
|
||||
width: 480,
|
||||
height: 320,
|
||||
}
|
||||
})
|
||||
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(ctx.arc).not.toHaveBeenCalled()
|
||||
expect(document.querySelector('[data-preview-selection-annotation="true"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns the compressed wrapper of the canvas dataURL', async () => {
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import html2canvas from 'html2canvas'
|
||||
import { compressDataUrl } from '../lib/imageCompress'
|
||||
import { computeAnnotationRect, drawAnnotation } from './annotate'
|
||||
|
||||
export type CaptureKind = 'full' | 'viewport' | 'element'
|
||||
|
||||
@ -16,24 +15,69 @@ export async function captureToDataUrl(kind: CaptureKind, element?: Element): Pr
|
||||
return compressDataUrl(canvas.toDataURL('image/png'))
|
||||
}
|
||||
|
||||
function setImportant(style: CSSStyleDeclaration, property: string, value: string): void {
|
||||
style.setProperty(property, value, 'important')
|
||||
}
|
||||
|
||||
function createAnnotationOverlay(el: Element, label: number | string): HTMLElement {
|
||||
const rect = el.getBoundingClientRect()
|
||||
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`)
|
||||
setImportant(overlay.style, 'width', `${rect.width}px`)
|
||||
setImportant(overlay.style, 'height', `${rect.height}px`)
|
||||
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, '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.textContent = String(label)
|
||||
setImportant(badge.style, 'position', 'absolute')
|
||||
setImportant(badge.style, 'top', '4px')
|
||||
setImportant(badge.style, 'right', '4px')
|
||||
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, 'border-radius', '999px')
|
||||
setImportant(badge.style, 'background', '#2f7bff')
|
||||
setImportant(badge.style, 'color', '#ffffff')
|
||||
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)
|
||||
|
||||
document.documentElement.appendChild(overlay)
|
||||
return overlay
|
||||
}
|
||||
|
||||
/** Viewport screenshot with the picked element's region annotated (blue box + numbered badge). 图4 */
|
||||
export async function captureAnnotatedRegion(el: Element, label = 1): Promise<string> {
|
||||
const elementRect = el.getBoundingClientRect()
|
||||
const viewportRect = { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight }
|
||||
const canvas = await html2canvas(document.documentElement, {
|
||||
useCORS: true,
|
||||
logging: false,
|
||||
scale: 1,
|
||||
x: window.scrollX,
|
||||
y: window.scrollY,
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
windowWidth: window.innerWidth,
|
||||
windowHeight: window.innerHeight,
|
||||
scrollX: window.scrollX,
|
||||
scrollY: window.scrollY,
|
||||
})
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (ctx) drawAnnotation(ctx, computeAnnotationRect(elementRect, viewportRect, 1), label)
|
||||
return compressDataUrl(canvas.toDataURL('image/png'))
|
||||
const overlay = createAnnotationOverlay(el, label)
|
||||
try {
|
||||
const canvas = await html2canvas(document.documentElement, {
|
||||
useCORS: true,
|
||||
logging: false,
|
||||
scale: 1,
|
||||
x: window.scrollX,
|
||||
y: window.scrollY,
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
windowWidth: window.innerWidth,
|
||||
windowHeight: window.innerHeight,
|
||||
scrollX: window.scrollX,
|
||||
scrollY: window.scrollY,
|
||||
})
|
||||
return compressDataUrl(canvas.toDataURL('image/png'))
|
||||
} finally {
|
||||
overlay.remove()
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user