diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c1f7eed56f0..cef0ee776afe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,28 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 9.5.0 + +### Important Changes + +We found some issues with the new feedback screenshot annotation where screenshots are not being generated properly. Due to this issue, we are reverting the feature. + +- Revert "feat(feedback) Allowing annotation via highlighting & masking ([#15484](https://github.com/getsentry/sentry-javascript/pull/15484))" (#15609) + +### Other Changes + +- Add cloudflare adapter detection and path generation ([#15603](https://github.com/getsentry/sentry-javascript/pull/15603)) +- deps(nextjs): Bump rollup to `4.34.9` ([#15589](https://github.com/getsentry/sentry-javascript/pull/15589)) +- feat(bun): Automatically add performance integrations ([#15586](https://github.com/getsentry/sentry-javascript/pull/15586)) +- feat(replay): Bump rrweb to 2.34.0 ([#15580](https://github.com/getsentry/sentry-javascript/pull/15580)) +- fix(browser): Call original function on early return from patched history API ([#15576](https://github.com/getsentry/sentry-javascript/pull/15576)) +- fix(nestjs): Copy metadata in custom decorators ([#15598](https://github.com/getsentry/sentry-javascript/pull/15598)) +- fix(react-router): Fix config type import ([#15583](https://github.com/getsentry/sentry-javascript/pull/15583)) +- fix(remix): Use correct types export for `@sentry/remix/cloudflare` ([#15599](https://github.com/getsentry/sentry-javascript/pull/15599)) +- fix(vue): Attach Pinia state only once per event ([#15588](https://github.com/getsentry/sentry-javascript/pull/15588)) + +Work in this release was contributed by @msurdi-a8c, @namoscato, and @rileyg98. Thank you for your contributions! + ## 9.4.0 - feat(core): Add types for logs protocol and envelope ([#15530](https://github.com/getsentry/sentry-javascript/pull/15530)) diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index f3fc284fc214..8ed7ea09805e 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -41,7 +41,7 @@ "dependencies": { "@babel/preset-typescript": "^7.16.7", "@playwright/test": "~1.50.0", - "@sentry-internal/rrweb": "2.33.0", + "@sentry-internal/rrweb": "2.34.0", "@sentry/browser": "9.4.0", "axios": "1.7.7", "babel-loader": "^8.2.2", diff --git a/packages/browser-utils/src/instrument/history.ts b/packages/browser-utils/src/instrument/history.ts index ad5ecb75f2ed..8494fca3076e 100644 --- a/packages/browser-utils/src/instrument/history.ts +++ b/packages/browser-utils/src/instrument/history.ts @@ -51,7 +51,7 @@ function instrumentHistory(): void { lastHref = to; if (from === to) { - return; + return originalHistoryFunction.apply(this, args); } const handlerData = { from, to } satisfies HandlerDataHistory; diff --git a/packages/bun/src/sdk.ts b/packages/bun/src/sdk.ts index 6c56a66aecea..734f331c25d8 100644 --- a/packages/bun/src/sdk.ts +++ b/packages/bun/src/sdk.ts @@ -2,6 +2,7 @@ import * as os from 'node:os'; import { applySdkMetadata, functionToStringIntegration, + hasSpansEnabled, inboundFiltersIntegration, linkedErrorsIntegration, requestDataIntegration, @@ -11,6 +12,7 @@ import type { NodeClient } from '@sentry/node'; import { consoleIntegration, contextLinesIntegration, + getAutoPerformanceIntegrations, httpIntegration, init as initNode, modulesIntegration, @@ -48,6 +50,7 @@ export function getDefaultIntegrations(_options: Options): Integration[] { modulesIntegration(), // Bun Specific bunServerIntegration(), + ...(hasSpansEnabled(_options) ? getAutoPerformanceIntegrations() : []), ]; } diff --git a/packages/bun/test/init.test.ts b/packages/bun/test/init.test.ts new file mode 100644 index 000000000000..45b146cf8ef4 --- /dev/null +++ b/packages/bun/test/init.test.ts @@ -0,0 +1,112 @@ +import { type Integration } from '@sentry/core'; +import * as sentryNode from '@sentry/node'; +import type { Mock } from 'bun:test'; +import { afterEach, beforeEach, describe, it, spyOn, mock, expect } from 'bun:test'; +import { getClient, init } from '../src'; + +const PUBLIC_DSN = 'https://username@domain/123'; + +class MockIntegration implements Integration { + public name: string; + public setupOnce: Mock<() => void>; + public constructor(name: string) { + this.name = name; + this.setupOnce = mock(() => undefined); + } +} + +describe('init()', () => { + let mockAutoPerformanceIntegrations: Mock<() => Integration[]>; + + beforeEach(() => { + // @ts-expect-error weird + mockAutoPerformanceIntegrations = spyOn(sentryNode, 'getAutoPerformanceIntegrations'); + }); + + afterEach(() => { + mockAutoPerformanceIntegrations.mockRestore(); + }); + + describe('integrations', () => { + it("doesn't install default integrations if told not to", () => { + init({ dsn: PUBLIC_DSN, defaultIntegrations: false }); + + const client = getClient(); + + expect(client?.getOptions().integrations).toEqual([]); + + expect(mockAutoPerformanceIntegrations).toHaveBeenCalledTimes(0); + }); + + it('installs merged default integrations, with overrides provided through options', () => { + const mockDefaultIntegrations = [ + new MockIntegration('Some mock integration 2.1'), + new MockIntegration('Some mock integration 2.2'), + ]; + + const mockIntegrations = [ + new MockIntegration('Some mock integration 2.1'), + new MockIntegration('Some mock integration 2.3'), + ]; + + init({ dsn: PUBLIC_DSN, integrations: mockIntegrations, defaultIntegrations: mockDefaultIntegrations }); + + expect(mockDefaultIntegrations[0]?.setupOnce).toHaveBeenCalledTimes(0); + expect(mockDefaultIntegrations[1]?.setupOnce).toHaveBeenCalledTimes(1); + expect(mockIntegrations[0]?.setupOnce).toHaveBeenCalledTimes(1); + expect(mockIntegrations[1]?.setupOnce).toHaveBeenCalledTimes(1); + expect(mockAutoPerformanceIntegrations).toHaveBeenCalledTimes(0); + }); + + it('installs integrations returned from a callback function', () => { + const mockDefaultIntegrations = [ + new MockIntegration('Some mock integration 3.1'), + new MockIntegration('Some mock integration 3.2'), + ]; + + const newIntegration = new MockIntegration('Some mock integration 3.3'); + + init({ + dsn: PUBLIC_DSN, + defaultIntegrations: mockDefaultIntegrations, + integrations: integrations => { + const newIntegrations = [...integrations]; + newIntegrations[1] = newIntegration; + return newIntegrations; + }, + }); + + expect(mockDefaultIntegrations[0]?.setupOnce).toHaveBeenCalledTimes(1); + expect(mockDefaultIntegrations[1]?.setupOnce).toHaveBeenCalledTimes(0); + expect(newIntegration.setupOnce).toHaveBeenCalledTimes(1); + expect(mockAutoPerformanceIntegrations).toHaveBeenCalledTimes(0); + }); + + it('installs performance default instrumentations if tracing is enabled', () => { + const autoPerformanceIntegrations = [new MockIntegration('Performance integration')]; + mockAutoPerformanceIntegrations.mockImplementation(() => autoPerformanceIntegrations); + + const mockIntegrations = [ + new MockIntegration('Some mock integration 4.1'), + new MockIntegration('Some mock integration 4.3'), + ]; + + init({ + dsn: PUBLIC_DSN, + integrations: mockIntegrations, + tracesSampleRate: 1, + }); + + expect(mockIntegrations[0]?.setupOnce).toHaveBeenCalledTimes(1); + expect(mockIntegrations[1]?.setupOnce).toHaveBeenCalledTimes(1); + expect(autoPerformanceIntegrations[0]?.setupOnce).toHaveBeenCalledTimes(1); + expect(mockAutoPerformanceIntegrations).toHaveBeenCalledTimes(1); + + const integrations = getClient()?.getOptions().integrations; + expect(integrations).toBeArray(); + expect(integrations?.map(({ name }) => name)).toContain('Performance integration'); + expect(integrations?.map(({ name }) => name)).toContain('Some mock integration 4.1'); + expect(integrations?.map(({ name }) => name)).toContain('Some mock integration 4.3'); + }); + }); +}); diff --git a/packages/core/src/types-hoist/feedback/config.ts b/packages/core/src/types-hoist/feedback/config.ts index 4ec846c7d98d..d7b3d78995bb 100644 --- a/packages/core/src/types-hoist/feedback/config.ts +++ b/packages/core/src/types-hoist/feedback/config.ts @@ -57,6 +57,15 @@ export interface FeedbackGeneralConfiguration { name: string; }; + /** + * _experiments allows users to enable experimental or internal features. + * We don't consider such features as part of the public API and hence we don't guarantee semver for them. + * Experimental features can be added, changed or removed at any time. + * + * Default: undefined + */ + _experiments: Partial<{ annotations: boolean }>; + /** * Set an object that will be merged sent as tags data with the event. */ diff --git a/packages/feedback/src/constants/index.ts b/packages/feedback/src/constants/index.ts index b568fb615ccb..198b6e199bb5 100644 --- a/packages/feedback/src/constants/index.ts +++ b/packages/feedback/src/constants/index.ts @@ -20,8 +20,8 @@ export const NAME_PLACEHOLDER = 'Your Name'; export const NAME_LABEL = 'Name'; export const SUCCESS_MESSAGE_TEXT = 'Thank you for your report!'; export const IS_REQUIRED_LABEL = '(required)'; -export const ADD_SCREENSHOT_LABEL = 'Capture Screenshot'; -export const REMOVE_SCREENSHOT_LABEL = 'Remove Screenshot'; +export const ADD_SCREENSHOT_LABEL = 'Add a screenshot'; +export const REMOVE_SCREENSHOT_LABEL = 'Remove screenshot'; export const FEEDBACK_WIDGET_SOURCE = 'widget'; export const FEEDBACK_API_SOURCE = 'api'; diff --git a/packages/feedback/src/core/integration.ts b/packages/feedback/src/core/integration.ts index e5f1092856f1..8b312b902258 100644 --- a/packages/feedback/src/core/integration.ts +++ b/packages/feedback/src/core/integration.ts @@ -84,6 +84,7 @@ export const buildFeedbackIntegration = ({ email: 'email', name: 'username', }, + _experiments = {}, tags, styleNonce, scriptNonce, @@ -158,6 +159,8 @@ export const buildFeedbackIntegration = ({ onSubmitError, onSubmitSuccess, onFormSubmitted, + + _experiments, }; let _shadow: ShadowRoot | null = null; diff --git a/packages/feedback/src/screenshot/components/Annotations.tsx b/packages/feedback/src/screenshot/components/Annotations.tsx new file mode 100644 index 000000000000..eb897b40f166 --- /dev/null +++ b/packages/feedback/src/screenshot/components/Annotations.tsx @@ -0,0 +1,91 @@ +import type { VNode, h as hType } from 'preact'; +import type * as Hooks from 'preact/hooks'; +import { DOCUMENT } from '../../constants'; + +interface FactoryParams { + h: typeof hType; +} + +export default function AnnotationsFactory({ + h, // eslint-disable-line @typescript-eslint/no-unused-vars +}: FactoryParams) { + return function Annotations({ + action, + imageBuffer, + annotatingRef, + }: { + action: 'crop' | 'annotate' | ''; + imageBuffer: HTMLCanvasElement; + annotatingRef: Hooks.Ref; + }): VNode { + const onAnnotateStart = (): void => { + if (action !== 'annotate') { + return; + } + + const handleMouseMove = (moveEvent: MouseEvent): void => { + const annotateCanvas = annotatingRef.current; + if (annotateCanvas) { + const rect = annotateCanvas.getBoundingClientRect(); + const x = moveEvent.clientX - rect.x; + const y = moveEvent.clientY - rect.y; + + const ctx = annotateCanvas.getContext('2d'); + if (ctx) { + ctx.lineTo(x, y); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(x, y); + } + } + }; + + const handleMouseUp = (): void => { + const ctx = annotatingRef.current?.getContext('2d'); + if (ctx) { + ctx.beginPath(); + } + + // Add your apply annotation logic here + applyAnnotation(); + + DOCUMENT.removeEventListener('mousemove', handleMouseMove); + DOCUMENT.removeEventListener('mouseup', handleMouseUp); + }; + + DOCUMENT.addEventListener('mousemove', handleMouseMove); + DOCUMENT.addEventListener('mouseup', handleMouseUp); + }; + + const applyAnnotation = (): void => { + // Logic to apply the annotation + const imageCtx = imageBuffer.getContext('2d'); + const annotateCanvas = annotatingRef.current; + if (imageCtx && annotateCanvas) { + imageCtx.drawImage( + annotateCanvas, + 0, + 0, + annotateCanvas.width, + annotateCanvas.height, + 0, + 0, + imageBuffer.width, + imageBuffer.height, + ); + + const annotateCtx = annotateCanvas.getContext('2d'); + if (annotateCtx) { + annotateCtx.clearRect(0, 0, annotateCanvas.width, annotateCanvas.height); + } + } + }; + return ( + + ); + }; +} diff --git a/packages/feedback/src/screenshot/components/Crop.tsx b/packages/feedback/src/screenshot/components/Crop.tsx new file mode 100644 index 000000000000..3b31ee71573c --- /dev/null +++ b/packages/feedback/src/screenshot/components/Crop.tsx @@ -0,0 +1,338 @@ +import type { FeedbackInternalOptions } from '@sentry/core'; +import type { VNode, h as hType } from 'preact'; +import type * as Hooks from 'preact/hooks'; +import { DOCUMENT, WINDOW } from '../../constants'; +import CropCornerFactory from './CropCorner'; + +const CROP_BUTTON_SIZE = 30; +const CROP_BUTTON_BORDER = 3; +const CROP_BUTTON_OFFSET = CROP_BUTTON_SIZE + CROP_BUTTON_BORDER; +const DPI = WINDOW.devicePixelRatio; + +interface Box { + startX: number; + startY: number; + endX: number; + endY: number; +} + +interface Rect { + x: number; + y: number; + height: number; + width: number; +} + +const constructRect = (box: Box): Rect => ({ + x: Math.min(box.startX, box.endX), + y: Math.min(box.startY, box.endY), + width: Math.abs(box.startX - box.endX), + height: Math.abs(box.startY - box.endY), +}); + +const getContainedSize = (img: HTMLCanvasElement): Rect => { + const imgClientHeight = img.clientHeight; + const imgClientWidth = img.clientWidth; + const ratio = img.width / img.height; + let width = imgClientHeight * ratio; + let height = imgClientHeight; + if (width > imgClientWidth) { + width = imgClientWidth; + height = imgClientWidth / ratio; + } + const x = (imgClientWidth - width) / 2; + const y = (imgClientHeight - height) / 2; + return { x: x, y: y, width: width, height: height }; +}; + +interface FactoryParams { + h: typeof hType; + hooks: typeof Hooks; + options: FeedbackInternalOptions; +} + +export default function CropFactory({ + h, + hooks, + options, +}: FactoryParams): (props: { + action: 'crop' | 'annotate' | ''; + imageBuffer: HTMLCanvasElement; + croppingRef: Hooks.Ref; + cropContainerRef: Hooks.Ref; + croppingRect: Box; + setCroppingRect: Hooks.StateUpdater; + resize: () => void; +}) => VNode { + const CropCorner = CropCornerFactory({ h }); + return function Crop({ + action, + imageBuffer, + croppingRef, + cropContainerRef, + croppingRect, + setCroppingRect, + resize, + }: { + action: 'crop' | 'annotate' | ''; + imageBuffer: HTMLCanvasElement; + croppingRef: Hooks.Ref; + cropContainerRef: Hooks.Ref; + croppingRect: Box; + setCroppingRect: Hooks.StateUpdater; + resize: () => void; + }): VNode { + const initialPositionRef = hooks.useRef({ initialX: 0, initialY: 0 }); + + const [isResizing, setIsResizing] = hooks.useState(false); + const [confirmCrop, setConfirmCrop] = hooks.useState(false); + + hooks.useEffect(() => { + const cropper = croppingRef.current; + if (!cropper) { + return; + } + + const ctx = cropper.getContext('2d'); + if (!ctx) { + return; + } + + const imageDimensions = getContainedSize(imageBuffer); + const croppingBox = constructRect(croppingRect); + ctx.clearRect(0, 0, imageDimensions.width, imageDimensions.height); + + if (action !== 'crop') { + return; + } + + // draw gray overlay around the selection + ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'; + ctx.fillRect(0, 0, imageDimensions.width, imageDimensions.height); + ctx.clearRect(croppingBox.x, croppingBox.y, croppingBox.width, croppingBox.height); + + // draw selection border + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 3; + ctx.strokeRect(croppingBox.x + 1, croppingBox.y + 1, croppingBox.width - 2, croppingBox.height - 2); + ctx.strokeStyle = '#000000'; + ctx.lineWidth = 1; + ctx.strokeRect(croppingBox.x + 3, croppingBox.y + 3, croppingBox.width - 6, croppingBox.height - 6); + }, [croppingRect, action]); + + // Resizing logic + const makeHandleMouseMove = hooks.useCallback((corner: string) => { + return (e: MouseEvent) => { + if (!croppingRef.current) { + return; + } + + const cropCanvas = croppingRef.current; + const cropBoundingRect = cropCanvas.getBoundingClientRect(); + const mouseX = e.clientX - cropBoundingRect.x; + const mouseY = e.clientY - cropBoundingRect.y; + + switch (corner) { + case 'top-left': + setCroppingRect(prev => ({ + ...prev, + startX: Math.min(Math.max(0, mouseX), prev.endX - CROP_BUTTON_OFFSET), + startY: Math.min(Math.max(0, mouseY), prev.endY - CROP_BUTTON_OFFSET), + })); + break; + case 'top-right': + setCroppingRect(prev => ({ + ...prev, + endX: Math.max(Math.min(mouseX, cropCanvas.width / DPI), prev.startX + CROP_BUTTON_OFFSET), + startY: Math.min(Math.max(0, mouseY), prev.endY - CROP_BUTTON_OFFSET), + })); + break; + case 'bottom-left': + setCroppingRect(prev => ({ + ...prev, + startX: Math.min(Math.max(0, mouseX), prev.endX - CROP_BUTTON_OFFSET), + endY: Math.max(Math.min(mouseY, cropCanvas.height / DPI), prev.startY + CROP_BUTTON_OFFSET), + })); + break; + case 'bottom-right': + setCroppingRect(prev => ({ + ...prev, + endX: Math.max(Math.min(mouseX, cropCanvas.width / DPI), prev.startX + CROP_BUTTON_OFFSET), + endY: Math.max(Math.min(mouseY, cropCanvas.height / DPI), prev.startY + CROP_BUTTON_OFFSET), + })); + break; + } + }; + }, []); + + // Dragging logic + const onDragStart = (e: MouseEvent): void => { + if (isResizing) { + return; + } + + initialPositionRef.current = { initialX: e.clientX, initialY: e.clientY }; + + const handleMouseMove = (moveEvent: MouseEvent): void => { + const cropCanvas = croppingRef.current; + if (!cropCanvas) { + return; + } + + const deltaX = moveEvent.clientX - initialPositionRef.current.initialX; + const deltaY = moveEvent.clientY - initialPositionRef.current.initialY; + + setCroppingRect(prev => { + const newStartX = Math.max( + 0, + Math.min(prev.startX + deltaX, cropCanvas.width / DPI - (prev.endX - prev.startX)), + ); + const newStartY = Math.max( + 0, + Math.min(prev.startY + deltaY, cropCanvas.height / DPI - (prev.endY - prev.startY)), + ); + + const newEndX = newStartX + (prev.endX - prev.startX); + const newEndY = newStartY + (prev.endY - prev.startY); + + initialPositionRef.current.initialX = moveEvent.clientX; + initialPositionRef.current.initialY = moveEvent.clientY; + + return { startX: newStartX, startY: newStartY, endX: newEndX, endY: newEndY }; + }); + }; + + const handleMouseUp = (): void => { + DOCUMENT.removeEventListener('mousemove', handleMouseMove); + DOCUMENT.removeEventListener('mouseup', handleMouseUp); + }; + + DOCUMENT.addEventListener('mousemove', handleMouseMove); + DOCUMENT.addEventListener('mouseup', handleMouseUp); + }; + + const onGrabButton = (e: Event, corner: string): void => { + setIsResizing(true); + const handleMouseMove = makeHandleMouseMove(corner); + const handleMouseUp = (): void => { + DOCUMENT.removeEventListener('mousemove', handleMouseMove); + DOCUMENT.removeEventListener('mouseup', handleMouseUp); + setConfirmCrop(true); + setIsResizing(false); + }; + + DOCUMENT.addEventListener('mouseup', handleMouseUp); + DOCUMENT.addEventListener('mousemove', handleMouseMove); + }; + + function applyCrop(): void { + const cutoutCanvas = DOCUMENT.createElement('canvas'); + const imageBox = getContainedSize(imageBuffer); + const croppingBox = constructRect(croppingRect); + cutoutCanvas.width = croppingBox.width * DPI; + cutoutCanvas.height = croppingBox.height * DPI; + + const cutoutCtx = cutoutCanvas.getContext('2d'); + if (cutoutCtx && imageBuffer) { + cutoutCtx.drawImage( + imageBuffer, + (croppingBox.x / imageBox.width) * imageBuffer.width, + (croppingBox.y / imageBox.height) * imageBuffer.height, + (croppingBox.width / imageBox.width) * imageBuffer.width, + (croppingBox.height / imageBox.height) * imageBuffer.height, + 0, + 0, + cutoutCanvas.width, + cutoutCanvas.height, + ); + } + + const ctx = imageBuffer.getContext('2d'); + if (ctx) { + ctx.clearRect(0, 0, imageBuffer.width, imageBuffer.height); + imageBuffer.width = cutoutCanvas.width; + imageBuffer.height = cutoutCanvas.height; + imageBuffer.style.width = `${croppingBox.width}px`; + imageBuffer.style.height = `${croppingBox.height}px`; + ctx.drawImage(cutoutCanvas, 0, 0); + + resize(); + } + } + + return ( +
+ + {action === 'crop' && ( +
+ + + + +
+ )} + {action === 'crop' && ( +
+ + +
+ )} +
+ ); + }; +} diff --git a/packages/feedback/src/screenshot/components/CropCorner.tsx b/packages/feedback/src/screenshot/components/CropCorner.tsx new file mode 100644 index 000000000000..de3b6e506e71 --- /dev/null +++ b/packages/feedback/src/screenshot/components/CropCorner.tsx @@ -0,0 +1,38 @@ +import type { VNode, h as hType } from 'preact'; + +interface FactoryParams { + h: typeof hType; +} + +export default function CropCornerFactory({ + h, // eslint-disable-line @typescript-eslint/no-unused-vars +}: FactoryParams) { + return function CropCorner({ + top, + left, + corner, + onGrabButton, + }: { + top: number; + left: number; + corner: string; + onGrabButton: (e: Event, corner: string) => void; + }): VNode { + return ( + + ); + }; +} diff --git a/packages/feedback/src/screenshot/components/CropIcon.tsx b/packages/feedback/src/screenshot/components/CropIcon.tsx new file mode 100644 index 000000000000..091179d86004 --- /dev/null +++ b/packages/feedback/src/screenshot/components/CropIcon.tsx @@ -0,0 +1,23 @@ +import type { VNode, h as hType } from 'preact'; + +interface FactoryParams { + h: typeof hType; +} + +export default function CropIconFactory({ + h, // eslint-disable-line @typescript-eslint/no-unused-vars +}: FactoryParams) { + return function CropIcon(): VNode { + return ( + + + + ); + }; +} diff --git a/packages/feedback/src/screenshot/components/IconClose.tsx b/packages/feedback/src/screenshot/components/IconClose.tsx deleted file mode 100644 index dea383a61839..000000000000 --- a/packages/feedback/src/screenshot/components/IconClose.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import type { VNode, h as hType } from 'preact'; - -interface FactoryParams { - h: typeof hType; -} - -export default function IconCloseFactory({ - h, // eslint-disable-line @typescript-eslint/no-unused-vars -}: FactoryParams) { - return function IconClose(): VNode { - return ( - - - - - - - ); - }; -} diff --git a/packages/feedback/src/screenshot/components/PenIcon.tsx b/packages/feedback/src/screenshot/components/PenIcon.tsx new file mode 100644 index 000000000000..75a0faedf480 --- /dev/null +++ b/packages/feedback/src/screenshot/components/PenIcon.tsx @@ -0,0 +1,31 @@ +import type { VNode, h as hType } from 'preact'; + +interface FactoryParams { + h: typeof hType; +} + +export default function PenIconFactory({ + h, // eslint-disable-line @typescript-eslint/no-unused-vars +}: FactoryParams) { + return function PenIcon(): VNode { + return ( + + + + + + ); + }; +} diff --git a/packages/feedback/src/screenshot/components/ScreenshotEditor.tsx b/packages/feedback/src/screenshot/components/ScreenshotEditor.tsx index 346a380d399d..9e8e708ec580 100644 --- a/packages/feedback/src/screenshot/components/ScreenshotEditor.tsx +++ b/packages/feedback/src/screenshot/components/ScreenshotEditor.tsx @@ -1,14 +1,16 @@ -/* eslint-disable max-lines */ import type { FeedbackInternalOptions, FeedbackModalIntegration } from '@sentry/core'; import type { ComponentType, VNode, h as hType } from 'preact'; import { h } from 'preact'; // eslint-disable-line @typescript-eslint/no-unused-vars import type * as Hooks from 'preact/hooks'; -import { DOCUMENT, WINDOW } from '../../constants'; -import IconCloseFactory from './IconClose'; +import { WINDOW } from '../../constants'; +import AnnotationsFactory from './Annotations'; +import CropFactory from './Crop'; import { createScreenshotInputStyles } from './ScreenshotInput.css'; import ToolbarFactory from './Toolbar'; import { useTakeScreenshotFactory } from './useTakeScreenshot'; +const DPI = WINDOW.devicePixelRatio; + interface FactoryParams { h: typeof hType; hooks: typeof Hooks; @@ -21,8 +23,6 @@ interface Props { onError: (error: Error) => void; } -type Action = 'highlight' | 'hide'; - interface Box { startX: number; startY: number; @@ -30,31 +30,17 @@ interface Box { endY: number; } -interface Dimensions { +interface Rect { x: number; y: number; height: number; width: number; } -interface Rect extends Dimensions { - action: Action; -} - -const DPI = WINDOW.devicePixelRatio; - -const constructRect = (action: Action, box: Box): Rect => ({ - action, - x: Math.min(box.startX, box.endX), - y: Math.min(box.startY, box.endY), - width: Math.abs(box.startX - box.endX), - height: Math.abs(box.startY - box.endY), -}); - -const getContainedSize = (measurementDiv: HTMLDivElement, imageSource: HTMLCanvasElement): Dimensions => { - const imgClientHeight = measurementDiv.clientHeight; - const imgClientWidth = measurementDiv.clientWidth; - const ratio = imageSource.width / imageSource.height; +const getContainedSize = (img: HTMLCanvasElement): Rect => { + const imgClientHeight = img.clientHeight; + const imgClientWidth = img.clientWidth; + const ratio = img.width / img.height; let width = imgClientHeight * ratio; let height = imgClientHeight; if (width > imgClientWidth) { @@ -66,53 +52,6 @@ const getContainedSize = (measurementDiv: HTMLDivElement, imageSource: HTMLCanva return { x: x, y: y, width: width, height: height }; }; -function drawRect(rect: Rect, ctx: CanvasRenderingContext2D, color: string, scale: number = 1): void { - const scaledX = rect.x * scale; - const scaledY = rect.y * scale; - const scaledWidth = rect.width * scale; - const scaledHeight = rect.height * scale; - - switch (rect.action) { - case 'highlight': { - // creates a shadow around - ctx.shadowColor = 'rgba(0, 0, 0, 0.7)'; - ctx.shadowBlur = 50; - - // draws a rectangle first so that the shadow is visible before clearing - ctx.fillStyle = 'rgb(0, 0, 0)'; - ctx.fillRect(scaledX, scaledY, scaledWidth, scaledHeight); - ctx.clearRect(scaledX, scaledY, scaledWidth, scaledHeight); - - // Disable shadow after the action is drawn - ctx.shadowColor = 'transparent'; - ctx.shadowBlur = 0; - - ctx.strokeStyle = color; - ctx.strokeRect(scaledX + 1, scaledY + 1, scaledWidth - 2, scaledHeight - 2); - - break; - } - case 'hide': - ctx.fillStyle = 'rgb(0, 0, 0)'; - ctx.fillRect(scaledX, scaledY, scaledWidth, scaledHeight); - - break; - default: - break; - } -} - -function resizeCanvas(canvas: HTMLCanvasElement, imageDimensions: Dimensions): void { - canvas.width = imageDimensions.width * DPI; - canvas.height = imageDimensions.height * DPI; - canvas.style.width = `${imageDimensions.width}px`; - canvas.style.height = `${imageDimensions.height}px`; - const ctx = canvas.getContext('2d'); - if (ctx) { - ctx.scale(DPI, DPI); - } -} - export function ScreenshotEditorFactory({ h, hooks, @@ -122,73 +61,23 @@ export function ScreenshotEditorFactory({ }: FactoryParams): ComponentType { const useTakeScreenshot = useTakeScreenshotFactory({ hooks }); const Toolbar = ToolbarFactory({ h }); - const IconClose = IconCloseFactory({ h }); - const styles = { __html: createScreenshotInputStyles(options.styleNonce).innerText }; + const Annotations = AnnotationsFactory({ h }); + const Crop = CropFactory({ h, hooks, options }); return function ScreenshotEditor({ onError }: Props): VNode { - // Data for rendering: - const [action, setAction] = hooks.useState('highlight'); - const [drawRects, setDrawRects] = hooks.useState([]); - const [currentRect, setCurrentRect] = hooks.useState(undefined); + const styles = hooks.useMemo(() => ({ __html: createScreenshotInputStyles(options.styleNonce).innerText }), []); - // Refs to our html components: - const measurementRef = hooks.useRef(null); - const screenshotRef = hooks.useRef(null); + const canvasContainerRef = hooks.useRef(null); + const cropContainerRef = hooks.useRef(null); const annotatingRef = hooks.useRef(null); - const rectContainerRef = hooks.useRef(null); - - // The canvas that contains the original screenshot - const [imageSource, setImageSource] = hooks.useState(null); - - // Hide the whole feedback widget when we take the screenshot - const [displayEditor, setDisplayEditor] = hooks.useState(true); - - // The size of our window, relative to the imageSource - const [scaleFactor, setScaleFactor] = hooks.useState(1); - - const strokeColor = hooks.useMemo((): string => { - const sentryFeedback = DOCUMENT.getElementById(options.id); - if (!sentryFeedback) { - return 'white'; - } - const computedStyle = getComputedStyle(sentryFeedback); - return ( - computedStyle.getPropertyValue('--button-primary-background') || - computedStyle.getPropertyValue('--accent-background') - ); - }, [options.id]); - - const resize = hooks.useCallback((): void => { - if (!displayEditor) { - return; - } - - const screenshotCanvas = screenshotRef.current; - const annotatingCanvas = annotatingRef.current; - const measurementDiv = measurementRef.current; - const rectContainer = rectContainerRef.current; - if (!screenshotCanvas || !annotatingCanvas || !imageSource || !measurementDiv || !rectContainer) { - return; - } - - const imageDimensions = getContainedSize(measurementDiv, imageSource); - - resizeCanvas(screenshotCanvas, imageDimensions); - resizeCanvas(annotatingCanvas, imageDimensions); - - rectContainer.style.width = `${imageDimensions.width}px`; - rectContainer.style.height = `${imageDimensions.height}px`; - - const scale = annotatingCanvas.clientWidth / imageBuffer.width; - setScaleFactor(scale); - - const screenshotContext = screenshotCanvas.getContext('2d', { alpha: false }); - if (!screenshotContext) { - return; - } - screenshotContext.drawImage(imageSource, 0, 0, imageDimensions.width, imageDimensions.height); - drawScene(); - }, [imageSource, drawRects, displayEditor]); + const croppingRef = hooks.useRef(null); + const [action, setAction] = hooks.useState<'annotate' | 'crop' | ''>('crop'); + const [croppingRect, setCroppingRect] = hooks.useState({ + startX: 0, + startY: 0, + endX: 0, + endY: 0, + }); hooks.useEffect(() => { WINDOW.addEventListener('resize', resize); @@ -196,192 +85,87 @@ export function ScreenshotEditorFactory({ return () => { WINDOW.removeEventListener('resize', resize); }; - }, [resize]); - - hooks.useLayoutEffect(() => { - resize(); - }, [resize]); - - hooks.useEffect(() => { - drawScene(); - drawBuffer(); - }, [drawRects]); + }, []); - hooks.useEffect(() => { - if (currentRect) { - drawScene(); - } - }, [currentRect]); - - // draws the commands onto the imageBuffer, which is what's sent to Sentry - const drawBuffer = hooks.useCallback((): void => { - const ctx = imageBuffer.getContext('2d', { alpha: false }); - const measurementDiv = measurementRef.current; - if (!imageBuffer || !ctx || !imageSource || !measurementDiv) { + function resizeCanvas(canvasRef: Hooks.Ref, imageDimensions: Rect): void { + const canvas = canvasRef.current; + if (!canvas) { return; } - ctx.drawImage(imageSource, 0, 0); - - const annotatingBufferBig = DOCUMENT.createElement('canvas'); - annotatingBufferBig.width = imageBuffer.width; - annotatingBufferBig.height = imageBuffer.height; - - const grayCtx = annotatingBufferBig.getContext('2d'); - if (!grayCtx) { - return; + canvas.width = imageDimensions.width * DPI; + canvas.height = imageDimensions.height * DPI; + canvas.style.width = `${imageDimensions.width}px`; + canvas.style.height = `${imageDimensions.height}px`; + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.scale(DPI, DPI); } + } - // applies the graywash if there's any boxes drawn - if (drawRects.length || currentRect) { - grayCtx.fillStyle = 'rgba(0, 0, 0, 0.25)'; - grayCtx.fillRect(0, 0, imageBuffer.width, imageBuffer.height); - } + function resize(): void { + const imageDimensions = getContainedSize(imageBuffer); - grayCtx.lineWidth = 4; - drawRects.forEach(rect => { - drawRect(rect, grayCtx, strokeColor); - }); - ctx.drawImage(annotatingBufferBig, 0, 0); - }, [drawRects, strokeColor]); + resizeCanvas(croppingRef, imageDimensions); + resizeCanvas(annotatingRef, imageDimensions); - const drawScene = hooks.useCallback((): void => { - const annotatingCanvas = annotatingRef.current; - if (!annotatingCanvas) { - return; + const cropContainer = cropContainerRef.current; + if (cropContainer) { + cropContainer.style.width = `${imageDimensions.width}px`; + cropContainer.style.height = `${imageDimensions.height}px`; } - const ctx = annotatingCanvas.getContext('2d'); - if (!ctx) { - return; - } - - ctx.clearRect(0, 0, annotatingCanvas.width, annotatingCanvas.height); - - // applies the graywash if there's any boxes drawn - if (drawRects.length || currentRect) { - ctx.fillStyle = 'rgba(0, 0, 0, 0.25)'; - ctx.fillRect(0, 0, annotatingCanvas.width, annotatingCanvas.height); - } - - ctx.lineWidth = 2; - const scale = annotatingCanvas.clientWidth / imageBuffer.width; - drawRects.forEach(rect => { - drawRect(rect, ctx, strokeColor, scale); - }); - - if (currentRect) { - drawRect(currentRect, ctx, strokeColor); - } - }, [drawRects, currentRect, strokeColor]); + setCroppingRect({ startX: 0, startY: 0, endX: imageDimensions.width, endY: imageDimensions.height }); + } useTakeScreenshot({ onBeforeScreenshot: hooks.useCallback(() => { (dialog.el as HTMLElement).style.display = 'none'; - setDisplayEditor(false); - }, []), - onScreenshot: hooks.useCallback((imageSource: HTMLVideoElement) => { - const bufferCanvas = DOCUMENT.createElement('canvas'); - bufferCanvas.width = imageSource.videoWidth; - bufferCanvas.height = imageSource.videoHeight; - bufferCanvas.getContext('2d', { alpha: false })?.drawImage(imageSource, 0, 0); - setImageSource(bufferCanvas); - - imageBuffer.width = imageSource.videoWidth; - imageBuffer.height = imageSource.videoHeight; }, []), + onScreenshot: hooks.useCallback( + (imageSource: HTMLVideoElement) => { + const context = imageBuffer.getContext('2d'); + if (!context) { + throw new Error('Could not get canvas context'); + } + imageBuffer.width = imageSource.videoWidth; + imageBuffer.height = imageSource.videoHeight; + imageBuffer.style.width = '100%'; + imageBuffer.style.height = '100%'; + context.drawImage(imageSource, 0, 0); + }, + [imageBuffer], + ), onAfterScreenshot: hooks.useCallback(() => { (dialog.el as HTMLElement).style.display = 'block'; - setDisplayEditor(true); + const container = canvasContainerRef.current; + container?.appendChild(imageBuffer); + resize(); }, []), onError: hooks.useCallback(error => { (dialog.el as HTMLElement).style.display = 'block'; - setDisplayEditor(true); onError(error); }, []), }); - const handleMouseDown = (e: MouseEvent): void => { - const annotatingCanvas = annotatingRef.current; - if (!action || !annotatingCanvas) { - return; - } - - const boundingRect = annotatingCanvas.getBoundingClientRect(); - - const startX = e.clientX - boundingRect.left; - const startY = e.clientY - boundingRect.top; - - const handleMouseMove = (e: MouseEvent): void => { - const endX = e.clientX - boundingRect.left; - const endY = e.clientY - boundingRect.top; - const rect = constructRect(action, { startX, startY, endX, endY }); - // prevent drawing when just clicking (not dragging) on the canvas - if (startX != endX && startY != endY) { - setCurrentRect(rect); - } - }; - - const handleMouseUp = (e: MouseEvent): void => { - // no rect is being drawn anymore, so setting active rect to undefined - setCurrentRect(undefined); - const endX = Math.max(0, Math.min(e.clientX - boundingRect.left, annotatingCanvas.width / DPI)); - const endY = Math.max(0, Math.min(e.clientY - boundingRect.top, annotatingCanvas.height / DPI)); - // prevent drawing a rect when just clicking (not dragging) on the canvas (ie. clicking delete) - if (startX != endX && startY != endY) { - // scale to image buffer - const scale = imageBuffer.width / annotatingCanvas.clientWidth; - const rect = constructRect(action, { - startX: startX * scale, - startY: startY * scale, - endX: endX * scale, - endY: endY * scale, - }); - setDrawRects(prev => [...prev, rect]); - } - - DOCUMENT.removeEventListener('mousemove', handleMouseMove); - DOCUMENT.removeEventListener('mouseup', handleMouseUp); - }; - - DOCUMENT.addEventListener('mousemove', handleMouseMove); - DOCUMENT.addEventListener('mouseup', handleMouseUp); - }; - - const handleDeleteRect = (index: number): void => { - const updatedRects = [...drawRects]; - updatedRects.splice(index, 1); - setDrawRects(updatedRects); - }; - return (