diff --git a/packages/client/src/feedback-controller.ts b/packages/client/src/feedback-controller.ts index b27e0d8..1c7e31f 100644 --- a/packages/client/src/feedback-controller.ts +++ b/packages/client/src/feedback-controller.ts @@ -76,7 +76,7 @@ function applyEnrichment(ctx: Record, el: Element): void { } } -function gatherBaseContext(el: Element): Record { +function gatherSummaryContext(el: Element): Record { const ctx: Record = { tag: el.tagName.toLowerCase(), path: getPath(el), @@ -84,6 +84,26 @@ function gatherBaseContext(el: Element): Record { label: getLabel(el), } + const feedbackContext = el.getAttribute('data-feedback-context') + if (feedbackContext) { + ctx['data-feedback-context'] = feedbackContext + } + + const dataIndex = el.getAttribute('data-index') + if (dataIndex) { + ctx['data-index'] = dataIndex + } + + if (el.tagName === 'IMG') { + ctx.img_src = (el as HTMLImageElement).src.replace(window.location.origin, '') + } + + return ctx +} + +function gatherBaseContext(el: Element): Record { + const ctx = gatherSummaryContext(el) + applyEnrichment(ctx, el) let current: Element | null = el @@ -275,7 +295,7 @@ export function createHeadlessFeedbackController( config: ResolvedConfig, trigger: FeedbackTrigger, ): FeedbackController { - const baseContext = gatherBaseContext(trigger.element) + const summaryContext = gatherSummaryContext(trigger.element) const listeners = new Set<(snapshot: FeedbackControllerSnapshot) => void>() const state: ControllerState = { text: '', @@ -289,13 +309,15 @@ export function createHeadlessFeedbackController( disposed: false, } - const breadcrumb = buildBreadcrumb(baseContext) + let pendingScreenshot: Promise | null = null + let screenshotStartTimer: ReturnType | null = null + + const breadcrumb = buildBreadcrumb(summaryContext) const targetLabel = ( - (baseContext.label as string) || - (baseContext.tag as string) || + (summaryContext.label as string) || + (summaryContext.tag as string) || '' ).substring(0, 60) - const pendingScreenshot = captureScreenshot(config, trigger.x, trigger.y) const getSnapshot = (): FeedbackControllerSnapshot => ({ x: trigger.x, @@ -319,10 +341,52 @@ export function createHeadlessFeedbackController( } } + const applyScreenshotResult = (screenshot: string | null): string | null => { + if (state.disposed) return screenshot + + state.screenshotData = screenshot + if (!screenshot) { + state.screenshotState = 'unavailable' + state.includeScreenshot = false + } else { + state.screenshotState = 'ready' + } + + notify() + return screenshot + } + + const clearScheduledScreenshotStart = () => { + if (screenshotStartTimer) { + clearTimeout(screenshotStartTimer) + screenshotStartTimer = null + } + } + + const startScreenshotCapture = (): Promise => { + if (state.disposed) return Promise.resolve(state.screenshotData) + if (pendingScreenshot) return pendingScreenshot + + clearScheduledScreenshotStart() + pendingScreenshot = captureScreenshot(config, trigger.x, trigger.y).then(applyScreenshotResult) + return pendingScreenshot + } + + const scheduleScreenshotCapture = () => { + if (!state.includeScreenshot || state.disposed || pendingScreenshot || screenshotStartTimer) { + return + } + + screenshotStartTimer = setTimeout(() => { + screenshotStartTimer = null + void startScreenshotCapture() + }, 0) + } + const getFullContext = (): Record => { if (state.fullContext) return state.fullContext - const nextContext = { ...baseContext } + const nextContext = gatherBaseContext(trigger.element) const formState = gatherFormState() if (formState) nextContext.form_state = formState state.fullContext = nextContext @@ -356,34 +420,13 @@ export function createHeadlessFeedbackController( } const ensureScreenshot = async (): Promise => { - const screenshot = state.screenshotData ?? (await pendingScreenshot) - if (state.disposed) return screenshot - - state.screenshotData = screenshot - if (!screenshot) { - state.screenshotState = 'unavailable' - state.includeScreenshot = false - } else { - state.screenshotState = 'ready' - } + if (state.screenshotState === 'ready') return state.screenshotData + if (state.screenshotState === 'unavailable') return null - notify() - return screenshot + return startScreenshotCapture() } - void pendingScreenshot.then((screenshot) => { - if (state.disposed) return - - state.screenshotData = screenshot - if (!screenshot) { - state.screenshotState = 'unavailable' - state.includeScreenshot = false - } else { - state.screenshotState = 'ready' - } - - notify() - }) + scheduleScreenshotCapture() return { getSnapshot, @@ -411,6 +454,11 @@ export function createHeadlessFeedbackController( if (state.submitState.kind !== 'idle') return if (include && state.screenshotState === 'unavailable') return state.includeScreenshot = include + if (include) { + scheduleScreenshotCapture() + } else { + clearScheduledScreenshotStart() + } notify() }, @@ -421,6 +469,10 @@ export function createHeadlessFeedbackController( }, getPayloadPreview() { + if (state.includeScreenshot && state.screenshotState === 'pending') { + scheduleScreenshotCapture() + } + return { event_type: 'feedback', page: window.location.pathname, @@ -498,7 +550,9 @@ export function createHeadlessFeedbackController( } else if (entry.value.result.ok) { const { deliveryId: id, deliveryUrl: url } = entry.value.result if (url) { - adapterSuccesses.push(`${entry.value.name} #${id ?? ''}`) + adapterSuccesses.push( + `${entry.value.name} #${id ?? ''}`, + ) hasDeliveryUrl = true } else { adapterSuccesses.push(id ? `${entry.value.name} #${id}` : entry.value.name) @@ -554,6 +608,7 @@ export function createHeadlessFeedbackController( }, dispose() { + clearScheduledScreenshotStart() state.disposed = true listeners.clear() }, diff --git a/packages/client/src/feedback.test.ts b/packages/client/src/feedback.test.ts index ed85d1c..50b5fec 100644 --- a/packages/client/src/feedback.test.ts +++ b/packages/client/src/feedback.test.ts @@ -1,5 +1,6 @@ // @vitest-environment jsdom +import html2canvas from 'html2canvas' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('./annotation.js', () => ({ @@ -35,6 +36,7 @@ import { initFeedback, showFeedbackDialog, } from './feedback.js' +import { enrichElement } from './plugins.js' import * as queue from './queue.js' import { resolveConfig } from './types.js' @@ -62,6 +64,7 @@ function createCanvasContext(): CanvasRenderingContext2D { async function flushUi(): Promise { await new Promise((resolve) => requestAnimationFrame(() => resolve())) + await new Promise((resolve) => setTimeout(resolve, 0)) await Promise.resolve() await Promise.resolve() } @@ -94,6 +97,15 @@ function createTarget(): HTMLElement { describe('feedback overlay', () => { beforeEach(() => { vi.restoreAllMocks() + vi.mocked(enrichElement).mockReset() + vi.mocked(enrichElement).mockReturnValue(null) + vi.mocked(html2canvas).mockReset() + vi.mocked(html2canvas).mockImplementation(async () => { + const canvas = document.createElement('canvas') + canvas.width = 900 + canvas.height = 600 + return canvas + }) vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(createCanvasContext()) vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockReturnValue( 'data:image/jpeg;base64,ZmFrZS1pbWFnZQ==', @@ -126,12 +138,16 @@ describe('feedback overlay', () => { const textarea = document.getElementById('__sf_text') as HTMLTextAreaElement const sendButton = document.getElementById('__sf_send') as HTMLButtonElement const annotateButton = document.getElementById('__sf_annotate') as HTMLButtonElement + const screenshotIndicator = document.getElementById( + '__sf_screenshot_indicator', + ) as HTMLDivElement const status = document.getElementById('__sf_status') as HTMLDivElement const detailsToggle = document.getElementById('__sf_details_toggle') as HTMLButtonElement const detailsPanel = document.getElementById('__sf_controls') as HTMLDivElement expect(document.activeElement).toBe(textarea) expect(sendButton.disabled).toBe(true) + expect(screenshotIndicator.textContent).toMatch(/screenshot ready/i) expect(status.textContent).toMatch(/screenshot attached/i) expect(annotateButton.textContent).toMatch(/annotate/i) expect(detailsToggle.textContent).toMatch(/details/i) @@ -144,6 +160,45 @@ describe('feedback overlay', () => { expect(sendButton.disabled).toBe(false) }) + it('keeps the dialog interactive while screenshot capture is still pending', async () => { + let resolveCapture: (canvas: HTMLCanvasElement) => void = () => {} + vi.mocked(html2canvas).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveCapture = resolve + }), + ) + + const target = createTarget() + showFeedbackDialog(target, 120, 80) + + await new Promise((resolve) => setTimeout(resolve, 0)) + await Promise.resolve() + + const textarea = document.getElementById('__sf_text') as HTMLTextAreaElement + const screenshotIndicator = document.getElementById( + '__sf_screenshot_indicator', + ) as HTMLDivElement + const status = document.getElementById('__sf_status') as HTMLDivElement + + expect(textarea).not.toBeNull() + expect(screenshotIndicator.textContent).toMatch(/screenshot loading/i) + expect(screenshotIndicator.getAttribute('aria-busy')).toBe('true') + expect(status.textContent).toMatch(/preparing screenshot in the background/i) + expect(vi.mocked(html2canvas)).toHaveBeenCalledOnce() + + const canvas = document.createElement('canvas') + canvas.width = 900 + canvas.height = 600 + resolveCapture(canvas) + + await flushUi() + + expect(screenshotIndicator.textContent).toMatch(/screenshot ready/i) + expect(screenshotIndicator.getAttribute('aria-busy')).toBe('false') + expect(status.textContent).toMatch(/screenshot attached/i) + }) + it('honors screenshot and context toggles in the queued payload', async () => { const pushSpy = vi.spyOn(queue, 'push').mockImplementation(() => {}) vi.spyOn(queue, 'flush').mockResolvedValue(true) @@ -156,6 +211,9 @@ describe('feedback overlay', () => { const detailsToggle = document.getElementById('__sf_details_toggle') as HTMLButtonElement const screenshotToggle = document.getElementById('__sf_include_screenshot') as HTMLInputElement const contextToggle = document.getElementById('__sf_include_context') as HTMLInputElement + const screenshotIndicator = document.getElementById( + '__sf_screenshot_indicator', + ) as HTMLDivElement const sendButton = document.getElementById('__sf_send') as HTMLButtonElement detailsToggle.click() @@ -165,6 +223,9 @@ describe('feedback overlay', () => { contextToggle.checked = false contextToggle.dispatchEvent(new Event('change', { bubbles: true })) + expect(screenshotIndicator.textContent).toMatch(/screenshot off/i) + expect(screenshotIndicator.getAttribute('aria-busy')).toBe('false') + textarea.value = 'Skip attachments for this quick note.' textarea.dispatchEvent(new Event('input', { bubbles: true })) sendButton.click() @@ -238,6 +299,34 @@ describe('feedback overlay', () => { expect(payloadPreview.textContent).not.toMatch(/"path"/) }) + it('defers plugin enrichment until the payload preview requests full context', async () => { + vi.mocked(enrichElement).mockReturnValue({ + componentName: 'FeedbackReviewCard', + fileName: 'src/App.tsx', + lineNumber: 192, + columnNumber: 7, + }) + + const target = createTarget() + + showFeedbackDialog(target, 120, 80) + await flushUi() + + expect(enrichElement).not.toHaveBeenCalled() + + const detailsToggle = document.getElementById('__sf_details_toggle') as HTMLButtonElement + const payloadToggle = document.getElementById('__sf_payload_toggle') as HTMLButtonElement + const payloadPreview = document.getElementById('__sf_payload_preview') as HTMLPreElement + + detailsToggle.click() + payloadToggle.click() + + expect(enrichElement).toHaveBeenCalledWith(target) + expect(payloadPreview.textContent).toMatch(/"component": "FeedbackReviewCard"/) + expect(payloadPreview.textContent).toMatch(/"source_file": "src\/App.tsx"/) + expect(payloadPreview.textContent).toMatch(/"source_line": 192/) + }) + it('defers the global form-state scan until context payload is requested', async () => { const querySelectorAllSpy = vi.spyOn(document, 'querySelectorAll') const target = createTarget() @@ -347,6 +436,15 @@ describe('feedback overlay', () => { describe('feedback controller', () => { beforeEach(() => { vi.restoreAllMocks() + vi.mocked(enrichElement).mockReset() + vi.mocked(enrichElement).mockReturnValue(null) + vi.mocked(html2canvas).mockReset() + vi.mocked(html2canvas).mockImplementation(async () => { + const canvas = document.createElement('canvas') + canvas.width = 900 + canvas.height = 600 + return canvas + }) vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(createCanvasContext()) vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockReturnValue( 'data:image/jpeg;base64,ZmFrZS1pbWFnZQ==', diff --git a/packages/client/src/feedback.ts b/packages/client/src/feedback.ts index cc0e71f..1a0d132 100644 --- a/packages/client/src/feedback.ts +++ b/packages/client/src/feedback.ts @@ -240,6 +240,10 @@ export function showFeedbackDialog( border-radius:${theme.panelRadius}; padding:8px; font-size:14px; resize:vertical; font-family:inherit; outline:none; min-height:104px; line-height:1.45;" > +
+ -- + Screenshot off +