From 8d9f523661416b7a749beb4031f9ae21edff0db3 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Thu, 6 Mar 2025 11:39:49 -0500 Subject: [PATCH] Revert "feat(feedback) Allowing annotation via highlighting & masking (#15484)" (#15609) Screenshots without annotations were not showing up properly (they are being capture as completely transparent). This reverts commit 9c1f79b79308f046eaee90710e4ae5ff449c3bc5. --- .../core/src/types-hoist/feedback/config.ts | 9 + packages/feedback/src/constants/index.ts | 4 +- packages/feedback/src/core/integration.ts | 3 + .../src/screenshot/components/Annotations.tsx | 91 +++++ .../src/screenshot/components/Crop.tsx | 338 ++++++++++++++++ .../src/screenshot/components/CropCorner.tsx | 38 ++ .../src/screenshot/components/CropIcon.tsx | 23 ++ .../src/screenshot/components/IconClose.tsx | 29 -- .../src/screenshot/components/PenIcon.tsx | 31 ++ .../components/ScreenshotEditor.tsx | 364 ++++-------------- .../components/ScreenshotInput.css.ts | 103 +++-- .../src/screenshot/components/Toolbar.tsx | 35 +- yarn.lock | 1 + 13 files changed, 703 insertions(+), 366 deletions(-) create mode 100644 packages/feedback/src/screenshot/components/Annotations.tsx create mode 100644 packages/feedback/src/screenshot/components/Crop.tsx create mode 100644 packages/feedback/src/screenshot/components/CropCorner.tsx create mode 100644 packages/feedback/src/screenshot/components/CropIcon.tsx delete mode 100644 packages/feedback/src/screenshot/components/IconClose.tsx create mode 100644 packages/feedback/src/screenshot/components/PenIcon.tsx 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 (