diff --git a/packages/core/src/types-hoist/feedback/config.ts b/packages/core/src/types-hoist/feedback/config.ts index d7b3d78995bb..4ec846c7d98d 100644 --- a/packages/core/src/types-hoist/feedback/config.ts +++ b/packages/core/src/types-hoist/feedback/config.ts @@ -57,15 +57,6 @@ 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/core/integration.ts b/packages/feedback/src/core/integration.ts index 8b312b902258..e5f1092856f1 100644 --- a/packages/feedback/src/core/integration.ts +++ b/packages/feedback/src/core/integration.ts @@ -84,7 +84,6 @@ export const buildFeedbackIntegration = ({ email: 'email', name: 'username', }, - _experiments = {}, tags, styleNonce, scriptNonce, @@ -159,8 +158,6 @@ 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 deleted file mode 100644 index eb897b40f166..000000000000 --- a/packages/feedback/src/screenshot/components/Annotations.tsx +++ /dev/null @@ -1,91 +0,0 @@ -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 deleted file mode 100644 index 3b31ee71573c..000000000000 --- a/packages/feedback/src/screenshot/components/Crop.tsx +++ /dev/null @@ -1,338 +0,0 @@ -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 deleted file mode 100644 index de3b6e506e71..000000000000 --- a/packages/feedback/src/screenshot/components/CropCorner.tsx +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index 091179d86004..000000000000 --- a/packages/feedback/src/screenshot/components/CropIcon.tsx +++ /dev/null @@ -1,23 +0,0 @@ -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 new file mode 100644 index 000000000000..dea383a61839 --- /dev/null +++ b/packages/feedback/src/screenshot/components/IconClose.tsx @@ -0,0 +1,29 @@ +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 deleted file mode 100644 index 75a0faedf480..000000000000 --- a/packages/feedback/src/screenshot/components/PenIcon.tsx +++ /dev/null @@ -1,31 +0,0 @@ -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 9e8e708ec580..ef76eac7f42a 100644 --- a/packages/feedback/src/screenshot/components/ScreenshotEditor.tsx +++ b/packages/feedback/src/screenshot/components/ScreenshotEditor.tsx @@ -2,20 +2,32 @@ import type { FeedbackInternalOptions, FeedbackModalIntegration } from '@sentry/ 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 { WINDOW } from '../../constants'; -import AnnotationsFactory from './Annotations'; -import CropFactory from './Crop'; +import { DOCUMENT, WINDOW } from '../../constants'; +import IconCloseFactory from './IconClose'; 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; - imageBuffer: HTMLCanvasElement; + + /** + * A ref to a Canvas Element that serves as our "value" or image output. + */ + outputBuffer: HTMLCanvasElement; + + /** + * A reference to the whole dialog (the parent of this component) so that we + * can show/hide it and take a clean screenshot of the webpage. + */ dialog: ReturnType; + + /** + * The whole options object. + * + * Needed to set nonce and id values for editor specific styles + */ options: FeedbackInternalOptions; } @@ -23,150 +35,329 @@ interface Props { onError: (error: Error) => void; } -interface Box { - startX: number; - startY: number; - endX: number; - endY: number; -} +type MaybeCanvas = HTMLCanvasElement | null; +type Screenshot = { canvas: HTMLCanvasElement; dpi: number }; -interface Rect { +type DrawType = 'highlight' | 'hide' | ''; +interface DrawCommand { + type: DrawType; x: number; y: number; - height: number; - width: number; + h: number; + w: number; +} + +function drawRect(command: DrawCommand, ctx: CanvasRenderingContext2D, color: string): void { + switch (command.type) { + case 'highlight': { + // creates a shadow around + ctx.shadowColor = 'rgba(0, 0, 0, 0.7)'; + ctx.shadowBlur = 50; + + // draws a rectangle first with a shadow + ctx.fillStyle = color; + ctx.fillRect(command.x - 1, command.y - 1, command.w + 2, command.h + 2); + + // cut out the inside of the rectangle + ctx.clearRect(command.x, command.y, command.w, command.h); + + break; + } + case 'hide': + ctx.fillStyle = 'rgb(0, 0, 0)'; + ctx.fillRect(command.x, command.y, command.w, command.h); + + break; + default: + break; + } } -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; +function with2dContext( + canvas: MaybeCanvas, + options: CanvasRenderingContext2DSettings, + callback: (canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) => void, +): void { + if (!canvas) { + return; } - const x = (imgClientWidth - width) / 2; - const y = (imgClientHeight - height) / 2; - return { x: x, y: y, width: width, height: height }; -}; + const ctx = canvas.getContext('2d', options); + if (!ctx) { + return; + } + callback(canvas, ctx); +} + +function paintImage(maybeDest: MaybeCanvas, source: HTMLCanvasElement): void { + with2dContext(maybeDest, { alpha: true }, (destCanvas, destCtx) => { + destCtx.drawImage(source, 0, 0, source.width, source.height, 0, 0, destCanvas.width, destCanvas.height); + }); +} + +// Paint the array of drawCommands into a canvas. +// Assuming this is the canvas foreground, and the background is cleaned. +function paintForeground(maybeCanvas: MaybeCanvas, strokeColor: string, drawCommands: DrawCommand[]): void { + with2dContext(maybeCanvas, { alpha: true }, (canvas, ctx) => { + // If there's anything to draw, then we'll first clear the canvas with + // a transparent grey background + if (drawCommands.length) { + ctx.fillStyle = 'rgba(0, 0, 0, 0.25)'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + } + + drawCommands.forEach(command => { + drawRect(command, ctx, strokeColor); + }); + }); +} export function ScreenshotEditorFactory({ h, hooks, - imageBuffer, + outputBuffer, dialog, options, }: FactoryParams): ComponentType { const useTakeScreenshot = useTakeScreenshotFactory({ hooks }); const Toolbar = ToolbarFactory({ h }); - const Annotations = AnnotationsFactory({ h }); - const Crop = CropFactory({ h, hooks, options }); - - return function ScreenshotEditor({ onError }: Props): VNode { - const styles = hooks.useMemo(() => ({ __html: createScreenshotInputStyles(options.styleNonce).innerText }), []); - - const canvasContainerRef = hooks.useRef(null); - const cropContainerRef = hooks.useRef(null); - const annotatingRef = hooks.useRef(null); - 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, - }); + const IconClose = IconCloseFactory({ h }); + const editorStyleInnerText = { __html: createScreenshotInputStyles(options.styleNonce).innerText }; - hooks.useEffect(() => { - WINDOW.addEventListener('resize', resize); + const dialogStyle = (dialog.el as HTMLElement).style; + + const ScreenshotEditor = ({ screenshot }: { screenshot: Screenshot }): VNode => { + // Data for rendering: + const [action, setAction] = hooks.useState('highlight'); + const [drawCommands, setDrawCommands] = hooks.useState([]); + + // Refs to our html components: + const measurementRef = hooks.useRef(null); + const backgroundRef = hooks.useRef(null); + const foregroundRef = hooks.useRef(null); + const mouseRef = hooks.useRef(null); + + // 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]); + + // The initial resize, to measure the area and set the children to the correct size + hooks.useLayoutEffect(() => { + const handleResize = (): void => { + const measurementDiv = measurementRef.current; + if (!measurementDiv) { + return; + } + with2dContext(screenshot.canvas, { alpha: false }, canvas => { + const scale = Math.min( + measurementDiv.clientWidth / canvas.width, + measurementDiv.clientHeight / canvas.height, + ); + setScaleFactor(scale); + }); + }; + + handleResize(); + WINDOW.addEventListener('resize', handleResize); return () => { - WINDOW.removeEventListener('resize', resize); + WINDOW.removeEventListener('resize', handleResize); }; - }, []); + }, [screenshot]); + + // Set the size of the canvas element to match our screenshot + const setCanvasSize = hooks.useCallback( + (maybeCanvas: MaybeCanvas, scale: number): void => { + with2dContext(maybeCanvas, { alpha: true }, (canvas, ctx) => { + // Must call `scale()` before setting `width` & `height` + ctx.scale(scale, scale); + canvas.width = screenshot.canvas.width; + canvas.height = screenshot.canvas.height; + }); + }, + [screenshot], + ); + + // Draw the screenshot into the background + hooks.useEffect(() => { + setCanvasSize(backgroundRef.current, screenshot.dpi); + paintImage(backgroundRef.current, screenshot.canvas); + }, [screenshot]); + + // Draw the commands into the foreground + hooks.useEffect(() => { + setCanvasSize(foregroundRef.current, screenshot.dpi); + with2dContext(foregroundRef.current, { alpha: true }, (canvas, ctx) => { + ctx.clearRect(0, 0, canvas.width, canvas.height); + }); + paintForeground(foregroundRef.current, strokeColor, drawCommands); + }, [drawCommands, strokeColor]); - function resizeCanvas(canvasRef: Hooks.Ref, imageDimensions: Rect): void { - const canvas = canvasRef.current; - if (!canvas) { + // Draw into the output outputBuffer + hooks.useEffect(() => { + setCanvasSize(outputBuffer, screenshot.dpi); + paintImage(outputBuffer, screenshot.canvas); + with2dContext(DOCUMENT.createElement('canvas'), { alpha: true }, (foreground, ctx) => { + ctx.scale(screenshot.dpi, screenshot.dpi); // The scale needs to be set before we set the width/height and paint + foreground.width = screenshot.canvas.width; + foreground.height = screenshot.canvas.height; + paintForeground(foreground, strokeColor, drawCommands); + paintImage(outputBuffer, foreground); + }); + }, [drawCommands, screenshot, strokeColor]); + + const handleMouseDown = (e: MouseEvent): void => { + if (!action || !mouseRef.current) { 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); - } - } + const boundingRect = mouseRef.current.getBoundingClientRect(); + const startingPoint: DrawCommand = { + type: action, + x: e.offsetX / scaleFactor, + y: e.offsetY / scaleFactor, + w: 0, + h: 0, + }; - function resize(): void { - const imageDimensions = getContainedSize(imageBuffer); + const getDrawCommand = (startingPoint: DrawCommand, e: MouseEvent): DrawCommand => { + const x = (e.clientX - boundingRect.x) / scaleFactor; + const y = (e.clientY - boundingRect.y) / scaleFactor; + return { + type: startingPoint.type, + x: Math.min(startingPoint.x, x), + y: Math.min(startingPoint.y, y), + w: Math.abs(x - startingPoint.x), + h: Math.abs(y - startingPoint.y), + } as DrawCommand; + }; - resizeCanvas(croppingRef, imageDimensions); - resizeCanvas(annotatingRef, imageDimensions); + const handleMouseMove = (e: MouseEvent): void => { + with2dContext(foregroundRef.current, { alpha: true }, (canvas, ctx) => { + ctx.clearRect(0, 0, canvas.width, canvas.height); + }); + paintForeground(foregroundRef.current, strokeColor, [...drawCommands, getDrawCommand(startingPoint, e)]); + }; - const cropContainer = cropContainerRef.current; - if (cropContainer) { - cropContainer.style.width = `${imageDimensions.width}px`; - cropContainer.style.height = `${imageDimensions.height}px`; - } + const handleMouseUp = (e: MouseEvent): void => { + const drawCommand = getDrawCommand(startingPoint, e); - setCroppingRect({ startX: 0, startY: 0, endX: imageDimensions.width, endY: imageDimensions.height }); - } + // Prevent just clicking onto the canvas, mouse has to move at least 1 pixel + if (drawCommand.w * scaleFactor >= 1 && drawCommand.h * scaleFactor >= 1) { + setDrawCommands(prev => [...prev, drawCommand]); + } + DOCUMENT.removeEventListener('mousemove', handleMouseMove); + DOCUMENT.removeEventListener('mouseup', handleMouseUp); + }; + + DOCUMENT.addEventListener('mousemove', handleMouseMove); + DOCUMENT.addEventListener('mouseup', handleMouseUp); + }; + + const deleteRect = hooks.useCallback((index: number): hType.JSX.MouseEventHandler => { + return (e: MouseEvent): void => { + e.preventDefault(); + e.stopPropagation(); + setDrawCommands(prev => { + const updatedRects = [...prev]; + updatedRects.splice(index, 1); + return updatedRects; + }); + }; + }, []); + + const dimensions = { + width: `${screenshot.canvas.width * scaleFactor}px`, + height: `${screenshot.canvas.height * scaleFactor}px`, + }; + + const handleStopPropagation = (e: MouseEvent): void => { + e.stopPropagation(); + }; + + return ( +
+