diff --git a/packages/feedback/src/sendFeedback.ts b/packages/feedback/src/sendFeedback.ts index e149a290e82a..a77a71b2ea39 100644 --- a/packages/feedback/src/sendFeedback.ts +++ b/packages/feedback/src/sendFeedback.ts @@ -2,13 +2,10 @@ import type { BrowserClient, Replay } from '@sentry/browser'; import { getCurrentHub } from '@sentry/core'; import { getLocationHref } from '@sentry/utils'; -import type { SendFeedbackOptions } from './types'; +import type { FeedbackFormData, Screenshot, SendFeedbackOptions } from './types'; import { sendFeedbackRequest } from './util/sendFeedbackRequest'; -interface SendFeedbackParams { - message: string; - name?: string; - email?: string; +interface SendFeedbackParams extends FeedbackFormData { url?: string; } @@ -18,6 +15,7 @@ interface SendFeedbackParams { export function sendFeedback( { name, email, message, url = getLocationHref() }: SendFeedbackParams, { includeReplay = true }: SendFeedbackOptions = {}, + screenshots: Screenshot[] = [], ): ReturnType { const client = getCurrentHub().getClient(); const replay = includeReplay && client ? (client.getIntegrationById('Replay') as Replay | undefined) : undefined; @@ -38,5 +36,6 @@ export function sendFeedback( url, replay_id: replayId, }, + screenshots, }); } diff --git a/packages/feedback/src/types/index.ts b/packages/feedback/src/types/index.ts index 6269e8a697a8..6cbbc808aab1 100644 --- a/packages/feedback/src/types/index.ts +++ b/packages/feedback/src/types/index.ts @@ -13,6 +13,7 @@ export interface SendFeedbackData { replay_id?: string; name?: string; }; + screenshots: Screenshot[]; } export interface SendFeedbackOptions { @@ -20,6 +21,8 @@ export interface SendFeedbackOptions { * Should include replay with the feedback? */ includeReplay?: boolean; + + screenshots?: Screenshot[]; } /** @@ -31,6 +34,8 @@ export interface FeedbackFormData { name?: string; } +export interface FeedbackFormDataWithOptionalScreenshots extends FeedbackFormData, OptionalScreenshotData {} + /** * General feedback configuration */ @@ -337,3 +342,21 @@ export interface FeedbackWidget { closeDialog: () => void; removeDialog: () => void; } + +export interface Rect { + height: number; + width: number; + x: number; + y: number; +} + +export interface OptionalScreenshotData { + screenshot?: Blob | null; + screenshotCutout?: Blob | null; +} + +export interface Screenshot { + filename: string; + data: Uint8Array; + contentType: string; +} diff --git a/packages/feedback/src/util/handleFeedbackSubmit.ts b/packages/feedback/src/util/handleFeedbackSubmit.ts index 6e7b7c014de4..44b6355b1f5a 100644 --- a/packages/feedback/src/util/handleFeedbackSubmit.ts +++ b/packages/feedback/src/util/handleFeedbackSubmit.ts @@ -2,7 +2,7 @@ import type { TransportMakeRequestResponse } from '@sentry/types'; import { logger } from '@sentry/utils'; import { sendFeedback } from '../sendFeedback'; -import type { FeedbackFormData, SendFeedbackOptions } from '../types'; +import type { FeedbackFormData, Screenshot, SendFeedbackOptions } from '../types'; import type { DialogComponent } from '../widget/Dialog'; /** @@ -13,6 +13,7 @@ export async function handleFeedbackSubmit( dialog: DialogComponent | null, feedback: FeedbackFormData, options?: SendFeedbackOptions, + screenshots?: Screenshot[], ): Promise { if (!dialog) { // Not sure when this would happen @@ -29,7 +30,7 @@ export async function handleFeedbackSubmit( dialog.hideError(); try { - const resp = await sendFeedback(feedback, options); + const resp = await sendFeedback(feedback, options, screenshots); // Success! return resp; diff --git a/packages/feedback/src/util/imageEditor/index.ts b/packages/feedback/src/util/imageEditor/index.ts new file mode 100644 index 000000000000..755aa6c7a87c --- /dev/null +++ b/packages/feedback/src/util/imageEditor/index.ts @@ -0,0 +1,402 @@ +import { WINDOW } from '@sentry/browser'; + +import type { IDrawing, ITool, Rect } from './types'; +import { Point, translateBoundingBoxToDocument, translateMouseEvent, translatePointToCanvas } from './utils'; + +interface Options { + canvas: HTMLCanvasElement; + image: HTMLImageElement; + onLoad?: () => void; +} + +const doc = WINDOW.document; + +class Resizer { + private boundingBox: Rect; + private box: HTMLDivElement; + private isDragging: boolean = false; + private isDraggingHandle: boolean = false; + + constructor(boundingBox: Rect, onDrag?: (event: MouseEvent) => void, onResize?: (event: MouseEvent) => void) { + this.boundingBox = boundingBox; + + const box = doc.createElement('div'); + this.box = box; + doc.body.appendChild(box); + + const horizontalDashedGradient = `repeating-linear-gradient( + to right, + white, + white 5px, + black 5px, + black 10px + )`; + const verticalDashedGradient = `repeating-linear-gradient( + to bottom, + white, + white 5px, + black 5px, + black 10px + )`; + + const topBorder = doc.createElement('div'); + topBorder.style.position = 'absolute'; + topBorder.style.width = 'calc(100% + 16px)'; + topBorder.style.height = '2px'; + topBorder.style.top = '-8px'; + topBorder.style.left = '-8px'; + topBorder.style.backgroundImage = horizontalDashedGradient; + + const bottomBorder = doc.createElement('div'); + bottomBorder.style.position = 'absolute'; + bottomBorder.style.width = 'calc(100% + 16px)'; + bottomBorder.style.height = '2px'; + bottomBorder.style.bottom = '-8px'; + bottomBorder.style.left = '-8px'; + bottomBorder.style.backgroundImage = horizontalDashedGradient; + + this.box.appendChild(topBorder); + this.box.appendChild(bottomBorder); + + const leftBorder = doc.createElement('div'); + leftBorder.style.position = 'absolute'; + leftBorder.style.height = 'calc(100% + 16px)'; + leftBorder.style.width = '2px'; + leftBorder.style.top = '-8px'; + leftBorder.style.left = '-8px'; + leftBorder.style.backgroundImage = verticalDashedGradient; + + const rightBorder = doc.createElement('div'); + rightBorder.style.position = 'absolute'; + rightBorder.style.height = 'calc(100% + 16px)'; + rightBorder.style.width = '2px'; + rightBorder.style.top = '-8px'; + rightBorder.style.right = '-8px'; + rightBorder.style.backgroundImage = verticalDashedGradient; + + this.box.appendChild(leftBorder); + this.box.appendChild(rightBorder); + + const handle = doc.createElement('div'); + handle.style.position = 'absolute'; + handle.style.width = '10px'; + handle.style.height = '10px'; + handle.style.borderRadius = '50%'; + handle.style.backgroundColor = 'white'; + handle.style.border = '2px solid black'; + handle.style.right = '-12px'; + handle.style.bottom = '-12px'; + handle.style.cursor = 'nwse-resize'; + handle.addEventListener('mousedown', e => { + e.stopPropagation(); + this.isDraggingHandle = true; + }); + this.box.appendChild(handle); + + this.box.addEventListener('mousedown', () => { + this.isDragging = true; + }); + + window.addEventListener('mouseup', () => { + this.isDragging = false; + this.isDraggingHandle = false; + }); + + window.addEventListener('mousemove', e => { + if (this.isDragging) { + onDrag?.(e); + } + if (this.isDraggingHandle) { + onResize?.(e); + } + }); + + this.updateStyles(); + } + + destroy() { + this.box.remove(); + } + + move(x: number, y: number) { + this.boundingBox = { + ...this.boundingBox, + x: this.boundingBox.x + x, + y: this.boundingBox.y + y, + }; + this.updateStyles(); + } + + resize(x: number, y: number) { + this.boundingBox = { + ...this.boundingBox, + width: this.boundingBox.width + x, + height: this.boundingBox.height + y, + }; + this.updateStyles(); + } + + private updateStyles() { + this.box.style.position = 'fixed'; + this.box.style.zIndex = '90000'; + this.box.style.width = `${Math.abs(this.boundingBox.width)}px`; + this.box.style.height = `${Math.abs(this.boundingBox.height)}px`; + this.box.style.left = `${this.boundingBox.x}px`; + this.box.style.top = `${this.boundingBox.y}px`; + this.box.style.cursor = 'move'; + this.box.style.transformOrigin = 'top left'; + + if (this.boundingBox.width < 0 && this.boundingBox.height < 0) { + this.box.style.transform = 'scale(-1)'; + } else if (this.boundingBox.width < 0) { + this.box.style.transform = 'scaleX(-1)'; + } else if (this.boundingBox.height < 0) { + this.box.style.transform = 'scaleY(-1)'; + } else { + this.box.style.transform = 'none'; + } + } +} + +const SCALEING_BASE = 1000 * 1000; + +const getScaling = (width: number, height: number) => { + const area = width * height; + return Math.max(Math.sqrt(area / SCALEING_BASE), 1); +}; + +/** + * + */ +export class ImageEditor { + private canvas: HTMLCanvasElement; + private ctx: CanvasRenderingContext2D | null; + private drawings: IDrawing[] = []; + private scheduledFrame: number | null = null; + private image: HTMLImageElement; + private isInteractive: boolean = false; + private selectedDrawingId: string | null = null; + private resizer: Resizer | null = null; + private drawingScaling: number = 1; + private _tool: ITool | null = null; + private _color: string = '#79628c'; + private _strokeSize: number = 6; + + constructor(options: Options) { + const { canvas, image, onLoad } = options; + this.canvas = canvas; + this.image = image; + this.ctx = canvas.getContext('2d'); + + if (image.complete) { + this.isInteractive = true; + this.canvas.width = image.width; + this.canvas.height = image.height; + this.drawingScaling = getScaling(image.width, image.height); + this.sheduleUpdateCanvas(); + onLoad?.(); + } else { + image.addEventListener('load', () => { + this.isInteractive = true; + this.canvas.width = image.width; + this.canvas.height = image.height; + this.drawingScaling = getScaling(image.width, image.height); + this.sheduleUpdateCanvas(); + onLoad && onLoad(); + }); + } + + this.canvas.addEventListener('click', this.handleClick); + this.canvas.addEventListener('mousedown', this.handleMouseDown); + window.addEventListener('mousemove', this.handleMouseMove, { passive: true }); + window.addEventListener('mouseup', this.handleMouseUp); + window.addEventListener('keydown', this.handleDelete); + } + + /** + * + */ + destroy() { + this.canvas.removeEventListener('click', this.handleClick); + this.canvas.removeEventListener('mousedown', this.handleMouseDown); + window.removeEventListener('mousemove', this.handleMouseMove); + window.removeEventListener('mouseup', this.handleMouseUp); + window.removeEventListener('keydown', this.handleDelete); + this.resizer?.destroy(); + this.drawings = []; + } + + /** + * + */ + set tool(tool: ITool | null) { + if (this._tool?.isDrawing) { + // end the current drawing and discard it + this._tool.endDrawing(Point.fromNumber(0)); + } + this._tool = tool; + // TODO(arthur): where to place this? + this.canvas.style.cursor = this._tool ? 'crosshair' : 'grab'; + } + + /** + * + */ + get tool(): ITool | null { + return this._tool; + } + + /** + * + */ + set color(color: string) { + this._color = color; + if (this.selectedDrawingId) { + const selectedDrawing = this.drawings.find(d => d.id === this.selectedDrawingId); + selectedDrawing?.setColor(color); + this.sheduleUpdateCanvas(); + } + } + + /** + * + */ + get color(): string { + return this._color; + } + + /** + * + */ + set strokeSize(strokeSize: number) { + this._strokeSize = strokeSize; + if (this.selectedDrawingId) { + const selectedDrawing = this.drawings.find(d => d.id === this.selectedDrawingId); + selectedDrawing?.setStrokeSize(strokeSize); + } + } + + /** + * + */ + get strokeSize(): number { + return this._strokeSize; + } + + private updateCanvas = () => { + if (!this.ctx) { + return; + } + + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + this.ctx.drawImage(this.image, 0, 0, this.canvas.width, this.canvas.height); + this.drawings.forEach(drawing => { + this.ctx && drawing.drawToCanvas(this.ctx, drawing.id === this.selectedDrawingId); + }); + if (this._tool && this._tool.isDrawing) { + const drawing = this._tool.getDrawingBuffer(); + if (drawing && this.ctx) { + drawing.drawToCanvas(this.ctx, false); + } + } + }; + + private sheduleUpdateCanvas = () => { + if (this.scheduledFrame) { + cancelAnimationFrame(this.scheduledFrame); + } + this.scheduledFrame = requestAnimationFrame(this.updateCanvas); + }; + + private handleClick = (e: MouseEvent) => { + if (this._tool || !this.isInteractive) { + return; + } + const point = translateMouseEvent(e, this.canvas); + const drawing = [...this.drawings].reverse().find(d => this.ctx && d.isInPath(this.ctx, point)); + this.selectedDrawingId = drawing && drawing.id || null; + this.sheduleUpdateCanvas(); + this.resizer?.destroy(); + this.resizer = null; + if (drawing) { + const boundingBox = drawing.getBoundingBox(); + this.resizer = new Resizer( + translateBoundingBoxToDocument(boundingBox, this.canvas), + this.handleDrag, + this.handleResize, + ); + } + }; + + private handleDelete = (e: KeyboardEvent) => { + if (!this.selectedDrawingId || !['Delete', 'Backspace'].includes(e.key)) { + return; + } + this.drawings = this.drawings.filter(d => d.id !== this.selectedDrawingId); + this.selectedDrawingId = null; + this.resizer?.destroy(); + this.resizer = null; + this.sheduleUpdateCanvas(); + }; + + private handleMouseDown = (e: MouseEvent) => { + if (!this._tool || this._tool.isDrawing || !this.isInteractive) { + return; + } + this._tool.startDrawing(translateMouseEvent(e, this.canvas), this._color, this.drawingScaling); + this.sheduleUpdateCanvas(); + }; + + private handleMouseMove = (e: MouseEvent) => { + if (!this._tool || !this._tool.isDrawing) { + return; + } + this._tool.draw(translateMouseEvent(e, this.canvas)); + this.sheduleUpdateCanvas(); + }; + + private handleMouseUp = (e: MouseEvent) => { + if (!this._tool || !this._tool.isDrawing) { + return; + } + const drawing = this._tool.endDrawing(translateMouseEvent(e, this.canvas)); + if (drawing) { + this.drawings.push(drawing); + } + this.sheduleUpdateCanvas(); + }; + + private handleDrag = (e: MouseEvent) => { + const selectedDrawing = this.drawings.find(d => d.id === this.selectedDrawingId); + if (!this.resizer || !this.selectedDrawingId) { + return; + } + const delta = Point.fromNumber(e.movementX, e.movementY); + selectedDrawing && selectedDrawing.moveBy(translatePointToCanvas(delta, this.canvas)); + this.resizer.move(e.movementX, e.movementY); + this.sheduleUpdateCanvas(); + }; + + private handleResize = (e: MouseEvent) => { + const selectedDrawing = this.drawings.find(d => d.id === this.selectedDrawingId); + if (!this.resizer || !this.selectedDrawingId) { + return; + } + const delta = Point.fromNumber(e.movementX, e.movementY); + selectedDrawing && selectedDrawing.scaleBy(translatePointToCanvas(delta, this.canvas)); + this.resizer.resize(e.movementX, e.movementY); + this.sheduleUpdateCanvas(); + }; + + public getDataURL = (): string => { + return this.canvas.toDataURL(); + }; + + public getBlob = (): Promise => { + return new Promise(resolve => { + this.canvas.toBlob(blob => { + resolve(blob); + }); + }); + }; +} diff --git a/packages/feedback/src/util/imageEditor/tool.ts b/packages/feedback/src/util/imageEditor/tool.ts new file mode 100644 index 000000000000..e21849d9736a --- /dev/null +++ b/packages/feedback/src/util/imageEditor/tool.ts @@ -0,0 +1,296 @@ +import type { IDrawing, IPoint, ITool, Rect } from './types'; +import { getPointsBoundingBox, Point, translateRect, updateBoundingBox, Vector } from './utils'; + +class Tool implements ITool { + private DrawingConstructor: new () => IDrawing; + private drawing: IDrawing | null = null; + + get isDrawing() { + return this.drawing !== null; + } + + constructor(DrawingConstructor: new () => IDrawing) { + this.DrawingConstructor = DrawingConstructor; + } + + startDrawing(point: IPoint, color: string, scalingFactor: number) { + this.drawing = new this.DrawingConstructor(); + this.drawing.setStrokeScalingFactor(scalingFactor); + this.drawing.setColor(color); + this.drawing.start(point); + } + + draw(point: IPoint) { + if (!this.isDrawing) { + throw new Error('Call startDrawing before calling draw'); + } + this.drawing && this.drawing.draw(point); + } + endDrawing(point: IPoint) { + if (!this.isDrawing) { + throw new Error('Call startDrawing before calling endDrawing'); + } + this.drawing && this.drawing.end(point); + const drawing = this.drawing; + this.drawing = null; + return drawing; + } + getDrawingBuffer() { + return this.drawing; + } +} + +class Drawing implements IDrawing { + protected path = new Path2D(); + protected startPoint: IPoint; + protected endPoint: IPoint; + protected translate: IPoint; + protected color = 'red'; + protected strokeSize = 6; + protected strokeScalingFactor = 1; + protected scalingFactorX = 1; + protected scalingFactorY = 1; + + public id = Math.random().toString(); + + constructor() { + this.start = this.start.bind(this); + this.draw = this.draw.bind(this); + this.end = this.end.bind(this); + this.isInPath = this.isInPath.bind(this); + this.drawToCanvas = this.drawToCanvas.bind(this); + this.getBoundingBox = this.getBoundingBox.bind(this); + this.startPoint = { x: 0, y: 0 }; + this.endPoint = { x: 0, y: 0 }; + this.translate = { x: 0, y: 0 }; + } + + get isValid() { + return true; + } + + get topLeftPoint() { + return Point.fromNumber(Math.min(this.startPoint.x, this.endPoint.x), Math.min(this.startPoint.y, this.endPoint.y)); + } + + get bottomRightPoint() { + return Point.fromNumber(Math.max(this.startPoint.x, this.endPoint.x), Math.max(this.startPoint.y, this.endPoint.y)); + } + + start(point: IPoint): void { + this.startPoint = point; + this.endPoint = point; + } + + draw(point: IPoint): void { + this.endPoint = point; + } + + end(point: IPoint): void { + this.endPoint = point; + } + + getBoundingBox() { + const box = getPointsBoundingBox([ + Point.add(this.startPoint, this.translate), + Point.add(this.endPoint, this.translate), + ]); + + return { + ...box, + width: box.width * this.scalingFactorX, + height: box.height * this.scalingFactorY, + }; + } + + setStrokeScalingFactor(strokeScalingFactor: number) { + this.strokeScalingFactor = strokeScalingFactor; + } + + setColor(color: string) { + this.color = color; + } + + setStrokeSize(strokeSize: number) { + this.strokeSize = strokeSize; + } + + isInPath(context: CanvasRenderingContext2D, point: IPoint) { + return this.withTransform( + context, + () => + // we check for multiple points to make selection easier + context.isPointInStroke(this.path, point.x, point.y) || + context.isPointInStroke(this.path, point.x + this.strokeSize, point.y) || + context.isPointInStroke(this.path, point.x - this.strokeSize, point.y) || + context.isPointInStroke(this.path, point.x, point.y + this.strokeSize) || + context.isPointInStroke(this.path, point.x, point.y - this.strokeSize) || + context.isPointInStroke(this.path, point.x + this.strokeSize, point.y + this.strokeSize) || + context.isPointInStroke(this.path, point.x - this.strokeSize, point.y - this.strokeSize), + ); + } + + private withTransform(context: CanvasRenderingContext2D, callback: () => T): T { + context.setTransform( + this.scalingFactorX, + 0, + 0, + this.scalingFactorY, + this.translate.x + this.topLeftPoint.x * (1 - this.scalingFactorX), + this.translate.y + this.topLeftPoint.y * (1 - this.scalingFactorY), + ); + const result = callback(); + // Reset current transformation matrix to the identity matrix + context.setTransform(1, 0, 0, 1, 0, 0); + return result; + } + + drawToCanvas(context: CanvasRenderingContext2D) { + if (!this.isValid) { + return; + } + + context.lineCap = 'round'; + context.lineJoin = 'round'; + context.strokeStyle = this.color; + context.lineWidth = this.strokeSize * this.strokeScalingFactor; + + this.withTransform(context, () => { + context.stroke(this.path); + }); + } + + scaleBy(delta: IPoint) { + const originalWidth = this.topLeftPoint.x - this.bottomRightPoint.x; + const originalHeight = this.topLeftPoint.y - this.bottomRightPoint.y; + const currentWidth = originalWidth * this.scalingFactorX; + const currentHeight = originalHeight * this.scalingFactorY; + + const newWidth = currentWidth - delta.x; + const newHeight = currentHeight - delta.y; + + this.scalingFactorX = newWidth / originalWidth; + this.scalingFactorY = newHeight / originalHeight; + } + + moveBy(point: IPoint) { + this.translate = Point.add(this.translate, point); + } +} + +class RectangleDrawing extends Drawing { + get isValid() { + return Point.distance(this.startPoint, this.endPoint) > 0; + } + + draw = (point: IPoint) => { + super.draw(point); + this.endPoint = point; + this.path = new Path2D(); + this.path.rect( + this.startPoint.x, + this.startPoint.y, + this.endPoint.x - this.startPoint.x, + this.endPoint.y - this.startPoint.y, + ); + }; +} + +/** + * + */ +export class Rectangle extends Tool { + constructor() { + super(RectangleDrawing); + } +} + +class PenDrawing extends Drawing { + private lastPoint: IPoint; + private boundingBox: Rect; + + constructor() { + super(); + this.lastPoint = { x: 0, y: 0 }; + this.boundingBox = { height: 0, width: 0, x: 0, y: 0 }; + } + getBoundingBox(): Rect { + const rect = translateRect(this.boundingBox, this.translate); + return { + ...rect, + width: rect.width * this.scalingFactorX, + height: rect.height * this.scalingFactorY, + }; + } + + get topLeftPoint() { + return Point.fromNumber(this.boundingBox.x, this.boundingBox.y); + } + + get bottomRightPoint() { + return Point.fromNumber(this.boundingBox.x + this.boundingBox.width, this.boundingBox.y + this.boundingBox.height); + } + + start = (point: IPoint) => { + super.start(point); + this.path.moveTo(point.x, point.y); + this.lastPoint = point; + this.boundingBox = getPointsBoundingBox([point]); + }; + + draw = (point: IPoint) => { + super.draw(point); + // Smooth the line + if (Point.distance(this.lastPoint, point) < 5) { + return; + } + this.lastPoint = point; + this.path.lineTo(point.x, point.y); + this.boundingBox = updateBoundingBox(this.boundingBox, [point]); + }; + + end = (point: IPoint) => { + this.path.lineTo(point.x, point.y); + this.boundingBox = updateBoundingBox(this.boundingBox, [point]); + }; +} + +/** + * + */ +export class Pen extends Tool { + constructor() { + super(PenDrawing); + } +} + +class ArrowDrawing extends Drawing { + get isValid() { + return Point.distance(this.startPoint, this.endPoint) > 0; + } + + draw = (point: IPoint) => { + super.draw(point); + + this.path = new Path2D(); + this.path.moveTo(this.startPoint.x, this.startPoint.y); + this.path.lineTo(this.endPoint.x, this.endPoint.y); + const unitVector = new Vector(Point.subtract(this.startPoint, this.endPoint)).normalize(); + const leftVector = unitVector.rotate(Math.PI / 5); + const rightVector = unitVector.rotate(-Math.PI / 5); + const leftPoint = Point.add(this.endPoint, Point.multiply(leftVector, 20 * this.strokeScalingFactor)); + const rightPoint = Point.add(this.endPoint, Point.multiply(rightVector, 20 * this.strokeScalingFactor)); + this.path.lineTo(leftPoint.x, leftPoint.y); + this.path.moveTo(this.endPoint.x, this.endPoint.y); + this.path.lineTo(rightPoint.x, rightPoint.y); + }; +} + +/** + * + */ +export class Arrow extends Tool { + constructor() { + super(ArrowDrawing); + } +} diff --git a/packages/feedback/src/util/imageEditor/types.ts b/packages/feedback/src/util/imageEditor/types.ts new file mode 100644 index 000000000000..f3c0c8670b7d --- /dev/null +++ b/packages/feedback/src/util/imageEditor/types.ts @@ -0,0 +1,35 @@ +export interface IPoint { + x: number; + y: number; +} + +export interface IDrawing { + draw: (point: IPoint) => void; + drawToCanvas: (context: CanvasRenderingContext2D, isSelected: boolean) => void; + end: (point: IPoint) => void; + getBoundingBox: () => Rect; + id: string; + isInPath: (ctx: CanvasRenderingContext2D, point: IPoint) => boolean; + get isValid(): boolean; + moveBy: (point: IPoint) => void; + scaleBy: (point: IPoint) => void; + setColor: (color: string) => void; + setStrokeScalingFactor: (scalingFactor: number) => void; + setStrokeSize: (strokeSize: number) => void; + start: (point: IPoint) => void; +} + +export interface ITool { + draw: (point: IPoint) => void; + endDrawing: (point: IPoint) => IDrawing | null; + getDrawingBuffer: () => IDrawing | null; + isDrawing: boolean; + startDrawing: (point: IPoint, color: string, scalingFactor: number) => void; +} + +export interface Rect { + height: number; + width: number; + x: number; + y: number; +} diff --git a/packages/feedback/src/util/imageEditor/utils.ts b/packages/feedback/src/util/imageEditor/utils.ts new file mode 100644 index 000000000000..d61f14655613 --- /dev/null +++ b/packages/feedback/src/util/imageEditor/utils.ts @@ -0,0 +1,237 @@ +import type { IPoint, Rect } from './types'; + +function asPoint(x: IPoint | number): IPoint { + return typeof x === 'number' ? Point.fromNumber(x) : x; +} + +/** + * + */ +export class Vector implements IPoint { + public x: number; + public y: number; + + /** + * + */ + public get length(): number { + return Point.distance(Point.fromNumber(0), this); + } + + /** + * + */ + static fromPoints(point1: IPoint, point2: IPoint): Vector { + return new Vector(Point.subtract(point2, point1)); + } + + constructor(point: IPoint) { + this.x = point.x; + this.y = point.y; + } + + /** + * + */ + normalize() { + const length = this.length; + return new Vector({ + x: this.x / length, + y: this.y / length, + }); + } + + /** + * + */ + rotate(angle: number) { + const cos = Math.cos(angle); + const sin = Math.sin(angle); + return new Vector({ + x: this.x * cos - this.y * sin, + y: this.x * sin + this.y * cos, + }); + } +} + +/** + * + */ +export class Point { + /** + * + */ + static fromMouseEvent(e: MouseEvent): IPoint { + return { + x: e.clientX, + y: e.clientY, + }; + } + + /** + * + */ + static fromNumber(x: number, y?: number): IPoint { + return { x, y: y ?? x }; + } + + /** + * + */ + static multiply(point: IPoint, multiplier: number | IPoint): IPoint { + const mult = asPoint(multiplier); + return { + x: point.x * mult.x, + y: point.y * mult.y, + }; + } + + /** + * + */ + static divide(point: IPoint, divisor: number | IPoint): IPoint { + const div = asPoint(divisor); + return { + x: point.x / div.x, + y: point.y / div.y, + }; + } + + /** + * + */ + static add(point1: IPoint, point2: number | IPoint): IPoint { + const point = asPoint(point2); + return { + x: point1.x + point.x, + y: point1.y + point.y, + }; + } + + /** + * + */ + static subtract(point1: IPoint, point2: number | IPoint): IPoint { + const point = asPoint(point2); + return { + x: point1.x - point.x, + y: point1.y - point.y, + }; + } + + /** + * + */ + static distance(point1: IPoint, point2: IPoint): number { + return Math.sqrt(Math.pow(point1.x - point2.x, 2) + Math.pow(point1.y - point2.y, 2)); + } + + /** + * + */ + static round(point: IPoint): IPoint { + return { + x: Math.round(point.x), + y: Math.round(point.y), + }; + } +} + +/** + * + */ +export function getCanvasScaleRatio(canvas: HTMLCanvasElement): IPoint { + const rect = canvas.getBoundingClientRect(); + const verticalScale = canvas.height / rect.height; + const horizontalScale = canvas.width / rect.width; + return { + x: horizontalScale, + y: verticalScale, + }; +} + +/** + * + */ +export function translatePoint(point: IPoint, ratio: IPoint): IPoint { + return Point.multiply(point, ratio); +} + +/** + * + */ +export function translatePointToDocument(point: IPoint, canvas: HTMLCanvasElement): IPoint { + return translatePoint(point, Point.divide(Point.fromNumber(1), getCanvasScaleRatio(canvas))); +} + +/** + * + */ +export function translateBoundingBoxToDocument(boundingBox: Rect, canvas: HTMLCanvasElement): Rect { + const start = translatePointToDocument(boundingBox, canvas); + const dimensions = translatePointToDocument(Point.fromNumber(boundingBox.width, boundingBox.height), canvas); + return { + x: start.x + canvas.getBoundingClientRect().left, + y: start.y + canvas.getBoundingClientRect().top, + width: dimensions.x, + height: dimensions.y, + }; +} + +/** + * + */ +export function translateMouseEvent(e: MouseEvent, canvas: HTMLCanvasElement): IPoint { + const ratio = getCanvasScaleRatio(canvas); + const clientRect = canvas.getBoundingClientRect(); + const canvasOffset = Point.fromNumber(clientRect.left, clientRect.top); + return Point.round(translatePoint(Point.subtract(Point.fromMouseEvent(e), canvasOffset), ratio)); +} + +/** + * + */ +export function translatePointToCanvas(point: IPoint, canvas: HTMLCanvasElement): IPoint { + return translatePoint(point, getCanvasScaleRatio(canvas)); +} + +/** + * + */ +export function translateRect(rect: Rect, vector: IPoint): Rect { + return { + x: rect.x + vector.x, + y: rect.y + vector.y, + width: rect.width, + height: rect.height, + }; +} + +/** + * + */ +export function getPointsBoundingBox(points: IPoint[]): Rect { + const xValues = points.map(p => p.x); + const yValues = points.map(p => p.y); + const minX = Math.min(...xValues); + const maxX = Math.max(...xValues); + const minY = Math.min(...yValues); + const maxY = Math.max(...yValues); + return { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + }; +} + +/** + * + */ +export function updateBoundingBox(boundingBox: Rect, points: IPoint[]): Rect { + return getPointsBoundingBox([ + ...points, + Point.fromNumber(boundingBox.x, boundingBox.y), + Point.fromNumber(boundingBox.x + boundingBox.width, boundingBox.y + boundingBox.height), + ]); +} diff --git a/packages/feedback/src/util/sendFeedbackRequest.ts b/packages/feedback/src/util/sendFeedbackRequest.ts index 45a3bd493de9..357366dbfcd1 100644 --- a/packages/feedback/src/util/sendFeedbackRequest.ts +++ b/packages/feedback/src/util/sendFeedbackRequest.ts @@ -1,5 +1,6 @@ import { createEventEnvelope, getCurrentHub } from '@sentry/core'; import type { FeedbackEvent, TransportMakeRequestResponse } from '@sentry/types'; +import { addItemToEnvelope, createAttachmentEnvelopeItem } from '@sentry/utils'; import type { SendFeedbackData } from '../types'; import { prepareFeedbackEvent } from './prepareFeedbackEvent'; @@ -9,10 +10,12 @@ import { prepareFeedbackEvent } from './prepareFeedbackEvent'; */ export async function sendFeedbackRequest({ feedback: { message, email, name, replay_id, url }, + screenshots, }: SendFeedbackData): Promise { const hub = getCurrentHub(); const client = hub.getClient(); const scope = hub.getScope(); + const options = client && client.getOptions(); const transport = client && client.getTransport(); const dsn = client && client.getDsn(); @@ -85,7 +88,17 @@ export async function sendFeedbackRequest({ } */ - const envelope = createEventEnvelope(feedbackEvent, dsn, client.getOptions()._metadata, client.getOptions().tunnel); + let envelope = createEventEnvelope(feedbackEvent, dsn, client.getOptions()._metadata, client.getOptions().tunnel); + + for (const attachment of screenshots || []) { + envelope = addItemToEnvelope( + envelope, + createAttachmentEnvelopeItem( + attachment, + options && options.transportOptions && options.transportOptions.textEncoder, + ), + ); + } let response: void | TransportMakeRequestResponse; diff --git a/packages/feedback/src/util/takeScreenshot.ts b/packages/feedback/src/util/takeScreenshot.ts new file mode 100644 index 000000000000..54312784d214 --- /dev/null +++ b/packages/feedback/src/util/takeScreenshot.ts @@ -0,0 +1,38 @@ +import { WINDOW } from '@sentry/browser'; + +/** + * Takes a screenshot + */ +export async function takeScreenshot(): Promise { + const stream = await WINDOW.navigator.mediaDevices.getDisplayMedia({ + video: { + width: WINDOW.innerWidth * WINDOW.devicePixelRatio, + height: WINDOW.innerHeight * WINDOW.devicePixelRatio, + }, + audio: false, + // @ts-expect-error safari/firefox only + preferCurrentTab: true, + surfaceSwitching: 'exclude', + }); + const videoTrack = stream.getVideoTracks()[0]; + const canvas = WINDOW.document.createElement('canvas'); + const context = canvas.getContext('2d'); + if (!context) { + throw new Error('Could not get canvas context'); + } + const video = WINDOW.document.createElement('video'); + video.srcObject = new MediaStream([videoTrack]); + + await new Promise(resolve => { + video.onloadedmetadata = () => { + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + context.drawImage(video, 0, 0); + stream.getTracks().forEach(track => track.stop()); + resolve(); + }; + void video.play(); + }); + + return canvas.toDataURL(); +} diff --git a/packages/feedback/src/widget/Dialog.ts b/packages/feedback/src/widget/Dialog.ts index 8fdfbba72f20..e5e4c85a4a26 100644 --- a/packages/feedback/src/widget/Dialog.ts +++ b/packages/feedback/src/widget/Dialog.ts @@ -1,13 +1,21 @@ -import type { FeedbackComponent, FeedbackInternalOptions } from '../types'; +import type { + FeedbackComponent, + FeedbackFormData, + FeedbackFormDataWithOptionalScreenshots, + FeedbackInternalOptions, + Screenshot, +} from '../types'; import type { FormComponentProps } from './Form'; import { Form } from './Form'; import { Logo } from './Logo'; +import { createScreenshotWidget } from './screenshot/createScreenshotWidget'; import { createElement } from './util/createElement'; export interface DialogProps - extends FormComponentProps, + extends Omit, Pick { onClosed?: () => void; + onSubmit: (feedback: FeedbackFormData, screenshots: Screenshot[]) => void; } export interface DialogComponent extends FeedbackComponent { @@ -94,18 +102,26 @@ export function Dialog({ return (el && el.open === true) || false; } + async function handleSubmit(feedback: FeedbackFormDataWithOptionalScreenshots) { + const screenshotData = (screenshot && (await screenshot.getData())) || {}; + onSubmit && onSubmit(feedback, screenshotData); + } + + const screenshot = createScreenshotWidget(); + const { el: formEl, showError, hideError, } = Form({ + screenshotForm: screenshot.ScreenshotForm, showEmail, showName, isAnonymous, defaultName, defaultEmail, - onSubmit, + onSubmit: handleSubmit, onCancel, ...textLabels, }); @@ -145,6 +161,8 @@ export function Dialog({ ), formEl, ), + screenshot.dialogEl, + screenshot.ScreenshotStyles, ); return { diff --git a/packages/feedback/src/widget/Form.ts b/packages/feedback/src/widget/Form.ts index 74ff06c015b9..801910c53932 100644 --- a/packages/feedback/src/widget/Form.ts +++ b/packages/feedback/src/widget/Form.ts @@ -1,4 +1,5 @@ import type { FeedbackComponent, FeedbackFormData, FeedbackInternalOptions, FeedbackTextConfiguration } from '../types'; +import type { ScreenshotFormComponent } from './screenshot/form'; import { SubmitButton } from './SubmitButton'; import { createElement } from './util/createElement'; @@ -18,6 +19,7 @@ export interface FormComponentProps * A default email value to render the input with. Empty strings are ok. */ defaultEmail: string; + screenshotForm?: ScreenshotFormComponent; onCancel?: (e: Event) => void; onSubmit?: (feedback: FeedbackFormData) => void; } @@ -61,6 +63,9 @@ export function Form({ defaultName, defaultEmail, + + screenshotForm, + onCancel, onSubmit, }: FormComponentProps): FormComponent { @@ -201,6 +206,8 @@ export function Form({ ], ), + screenshotForm && screenshotForm.el, + createElement( 'div', { diff --git a/packages/feedback/src/widget/createWidget.ts b/packages/feedback/src/widget/createWidget.ts index 480e3d476219..4f7fe956869e 100644 --- a/packages/feedback/src/widget/createWidget.ts +++ b/packages/feedback/src/widget/createWidget.ts @@ -1,7 +1,7 @@ import { getCurrentHub } from '@sentry/core'; import { logger } from '@sentry/utils'; -import type { FeedbackFormData, FeedbackInternalOptions, FeedbackWidget } from '../types'; +import type { FeedbackFormData, FeedbackInternalOptions, FeedbackWidget, Screenshot } from '../types'; import { handleFeedbackSubmit } from '../util/handleFeedbackSubmit'; import type { ActorComponent } from './Actor'; import { Actor } from './Actor'; @@ -83,7 +83,7 @@ export function createWidget({ * Handler for when the feedback form is completed by the user. This will * create and send the feedback message as an event. */ - async function _handleFeedbackSubmit(feedback: FeedbackFormData): Promise { + async function _handleFeedbackSubmit(feedback: FeedbackFormData, screenshots?: Screenshot[]): Promise { if (!dialog) { return; } @@ -94,7 +94,7 @@ export function createWidget({ return; } - const result = await handleFeedbackSubmit(dialog, feedback); + const result = await handleFeedbackSubmit(dialog, feedback, {}, screenshots); // Error submitting feedback if (!result) { @@ -200,6 +200,7 @@ export function createWidget({ } catch (err) { // TODO: Error handling? logger.error(err); + console.error(err); } } diff --git a/packages/feedback/src/widget/screenshot/Screenshot.css.ts b/packages/feedback/src/widget/screenshot/Screenshot.css.ts new file mode 100644 index 000000000000..93c4092f982a --- /dev/null +++ b/packages/feedback/src/widget/screenshot/Screenshot.css.ts @@ -0,0 +1,278 @@ +/** + * + */ +export function createScreenshotStyles(d: Document): HTMLStyleElement { + const style = d.createElement('style'); + + style.textContent = ` +.screenshot-editor__row { +display: flex; +flex-direction: column; +gap: 6px; +} + +.screenshot-editor { + position: absolute; + cursor: crosshair; + max-width: 100vw; + max-height: 100vh; +} + +.screenshot-editor__container { + position: fixed; + z-index: 10000; + height: 100vh; + width: 100vw; + top: 0; + left: 0; + background-color: rgba(240, 236, 243, 1); + background-image: repeating-linear-gradient( + 45deg, + transparent, + transparent 5px, + rgba(0, 0, 0, 0.03) 5px, + rgba(0, 0, 0, 0.03) 10px + ); +} + +.screenshot-editor__container[aria-hidden="true"] { + display: none; +} + +.screenshot-editor__help { + position: fixed; + width: 100vw; + padding-top: 8px; + left: 0; + pointer-events: none; + display: flex; + justify-content: center; + transition: transform 0.2s ease-in-out; + transition-delay: 0.5s; + transform: translateY(0); + &[data-hide='true'] { + transition-delay: 0s; + transform: translateY(-100%); + } +} + +.screenshot-editor__help__content { + background-color: #231c3d; + border: 1px solid #ccc; + border-radius: 20px; + color: #fff; + font-size: 14px; + padding: 6px 24px; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), 0 4px 16px rgba(0, 0, 0, 0.2); +} + +.screenshot-preview__wrapper { + display: flex; + gap: 8px; + width: 100%; +} + +.screenshot-preview { + position: relative; + display: block; + flex: 1; + min-width: 0; + height: 160px; + border-radius: 4px; + border: 1px solid #ccc; + overflow: hidden; +} +.screenshot-preview[aria-hidden="true"] { +display: none; +} +.screenshot-preview::after { + content: 'Edit'; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + color: #fff; + background-color: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + font-weight: 600; + opacity: 0; + transition: opacity 0.2s ease-in-out; +} +screenshot-preview:hover::after{ + opacity: 1; +} + +.screenshot-preview__image { + display: block; + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + object-fit: contain; + background-image: repeating-linear-gradient( + 45deg, + transparent, + transparent 5px, + rgba(0, 0, 0, 0.03) 5px, + rgba(0, 0, 0, 0.03) 10px + ); +} + +.screenshot-annotator__container { + position: fixed; + z-index: 10000; + height: 100vh; + width: 100vw; + top: 0; + left: 0; + background-color: rgba(240, 236, 243, 1); + background-image: repeating-linear-gradient( + 45deg, + transparent, + transparent 5px, + rgba(0, 0, 0, 0.03) 5px, + rgba(0, 0, 0, 0.03) 10px + ); +} + +.screenshot-annotator__container[aria-hidden="true"] { + display: none; +} + +.screenshot-annotator__canvas { + cursor: crosshair; + max-width: 100vw; + max-height: 100vh; +} + +.screenshot-annotator__canvas__wrapper { + position: relative; + width: 100%; + margin-top: 32px; + height: calc(100% - 96px); + display: flex; + align-items: center; + justify-content: center; +} + +.screenshot-annotator__toolbar { + position: absolute; +left: 16px; +right: 16px; + bottom: 0px; + padding: 12px 0; + display: flex; + gap: 12px; + flex-direction: row; + justify-content: space-between; + align-items: center; +} + +.screenshot-annotator__toolbar__group { + display: flex; + flex-direction: row; + background-color: white; + border: rgba(58, 17, 95, 0.14) 1px solid; + border-radius: 10px; + padding: 4px; + overflow: hidden; + gap: 4px; + box-shadow: 0px 1px 2px 1px rgba(43, 34, 51, 0.04); +} + +.screenshot-annotator__tool-button { + width: 32px; + height: 32px; + border-radius: 6px; + border: none; + background-color: white; + color: rgba(43, 34, 51, 1); + font-size: 16px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + &:hover { + background-color: rgba(43, 34, 51, 0.06); + } +} + +.screenshot-annotator__tool-button--active { + background-color: rgba(108, 95, 199, 1) !important; + color: white; +} + +.screenshot-annotator__tool-icon { + +} + +.screenshot-annotator__spacer { + flex: 1; +} + +.screenshot-annotator__cancel { + height: 40px; + width: 84px; + border: rgba(58, 17, 95, 0.14) 1px solid; + background-color: #fff; + color: rgba(43, 34, 51, 1); + font-size: 14px; + font-weight: 500; + cursor: pointer; + border-radius: 10px; + &:hover { + background-color: #eee; + } + +} + +.screenshot-annotator__submit { + height: 40px; + width: 84px; + border: none; + background-color: rgba(108, 95, 199, 1); + color: #fff; + font-size: 14px; + font-weight: 500; + cursor: pointer; + border-radius: 10px; + &:hover { + background-color: rgba(88, 74, 192, 1); + } + +} + +.screenshot-annotator__color-display { + width: 16px; + height: 16px; + border-radius: 4px; + +} +.screenshot-annotator__color-input { + position: relative; + display: flex; + width: 32px; + height: 32px; + align-items: center; + justify-content: center; + margin: 0; + cursor: pointer; + & input[type='color'] { + position: absolute; + top: 0; + left: 0; + opacity: 0; + width: 0; + height: 0; + } +} +`; + + return style; +} diff --git a/packages/feedback/src/widget/screenshot/ScreenshotAnnotator.ts b/packages/feedback/src/widget/screenshot/ScreenshotAnnotator.ts new file mode 100644 index 000000000000..5a713b7ffafc --- /dev/null +++ b/packages/feedback/src/widget/screenshot/ScreenshotAnnotator.ts @@ -0,0 +1,234 @@ +import { WINDOW } from '@sentry/browser'; + +import { ImageEditor } from '../../util/imageEditor'; +import { Arrow, Pen, Rectangle } from '../../util/imageEditor/tool'; +import { createElement } from '../util/createElement'; +import type { IconComponent} from './icons'; +import { ArrowIcon, HandIcon, PenIcon, RectangleIcon } from './icons'; + +export type ToolKey = 'arrow' | 'pen' | 'rectangle' | 'hand'; +export const Tools: ToolKey[] = ['arrow', 'pen', 'rectangle', 'hand']; +export interface Rect { + height: number; + width: number; + x: number; + y: number; +} +interface ScreenshotAnnotatorProps { + onCancel: () => void; + onSubmit: (screenshot: Blob) => void; +} + +const iconMap: Record, () => IconComponent> = { + arrow: ArrowIcon, + pen: PenIcon, + rectangle: RectangleIcon, + hand: HandIcon, +}; + +const getCanvasRenderSize = (canvas: HTMLCanvasElement, containerElement: HTMLDivElement) => { + const canvasWidth = canvas.width; + const canvasHeight = canvas.height; + const maxWidth = containerElement.getBoundingClientRect().width; + const maxHeight = containerElement.getBoundingClientRect().height; + // fit canvas to window + let width = canvasWidth; + let height = canvasHeight; + const canvasRatio = canvasWidth / canvasHeight; + const windowRatio = maxWidth / maxHeight; + + if (canvasRatio > windowRatio && canvasWidth > maxWidth) { + height = (maxWidth / canvasWidth) * canvasHeight; + width = maxWidth; + } + + if (canvasRatio < windowRatio && canvasHeight > maxHeight) { + width = (maxHeight / canvasHeight) * canvasWidth; + height = maxHeight; + } + + return { width, height }; +}; + +const srcToImage = (src: string): HTMLImageElement => { + const image = new Image(); + image.src = src; + return image; +}; + +function ToolIcon({ tool }: { tool: ToolKey | null }) { + const Icon = tool ? iconMap[tool] : HandIcon; + return Icon(); +} + +const DEFAULT_COLOR = '#ff7738'; + +/** + * + */ +export function ScreenshotAnnotator({ onCancel, onSubmit }: ScreenshotAnnotatorProps) { + let editor: ImageEditor | null = null; + + const canvas = createElement('canvas', { className: '.screenshot-annotator__canvas' }); + const wrapper = createElement('div', { className: 'screenshot-annotator__canvas__wrapper' }, canvas); + const tools = new Map( + Tools.map(tool => [ + tool, + createElement( + 'button', + { className: 'screenshot-annotator__tool-button', onClick: () => setSelectedTool(tool) }, + ToolIcon({ tool }).el, + ), + ]), + ); + + const toolbarGroupTools = createElement( + 'div', + { className: 'screenshot-annotator__toolbar__group' }, + Array.from(tools.values()), + ); + const colorDisplay = createElement('div', { + className: 'screenshot-annotator__color-display', + style: `background-color: ${DEFAULT_COLOR}`, + }); + + const colorInput = createElement('input', { + type: 'color', + value: DEFAULT_COLOR, + onChange: (e: Event): void => { + e.target && setSelectedColor((e.target as HTMLInputElement).value); + }, + }); + + const colorInputLabel = createElement( + 'label', + { + className: 'screenshot-annotator__color-input', + }, + colorDisplay, + colorInput, + ); + const toolbarGroupColor = createElement( + 'div', + { className: 'screenshot-annotator__toolbar__group' }, + colorInputLabel, + ); + const toolbar = createElement( + 'div', + { className: 'screenshot-annotator__toolbar' }, + createElement('button', { className: 'screenshot-annotator__cancel', onClick: onCancel }, 'Cancel'), + createElement('div', {}, toolbarGroupTools, toolbarGroupColor), + createElement('button', { className: 'screenshot-annotator__submit', onClick: handleSubmit }, 'Save'), + ); + + const el = createElement( + 'div', + { + className: 'screenshot-annotator__container', + 'aria-hidden': 'true', + onClick: (e: Event) => { + e.stopPropagation(); + }, + }, + wrapper, + toolbar, + ); + + const resizeCanvas = () => { + if (!canvas) { + return; + } + // fit canvas to window + const { width, height } = getCanvasRenderSize(canvas, wrapper); + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + }; + + WINDOW.addEventListener('resize', resizeCanvas); + + /** + * + */ + async function handleSubmit(): Promise { + if (!editor) { + return; + } + + const blob = await editor.getBlob(); + + if (!blob) { + return; + } + + onSubmit(blob); + } + /** + * + */ + function setSelectedColor(color: string) { + if (!editor) { + return; + } + + editor.color = color; + colorDisplay.setAttribute('style', `background-color: ${color};`); + colorInput.value = color; + } + + /** + * + */ + function setSelectedTool(tool: ToolKey) { + if (!editor) { + return; + } + + // if (activeTool) { + // // activeTool. + // } + + const activeTools = toolbarGroupTools.querySelectorAll('.screenshot-annotator__tool-button--active'); + activeTools.forEach(activeTool => { + activeTool.classList.remove('screenshot-annotator__tool-button--active'); + }); + + const toolEl = tools.get(tool); + if (toolEl) { + toolEl.classList.add('screenshot-annotator__tool-button--active'); + } + + switch (tool) { + case 'arrow': + editor.tool = new Arrow(); + break; + case 'pen': + editor.tool = new Pen(); + break; + case 'rectangle': + editor.tool = new Rectangle(); + break; + default: + editor.tool = null; + break; + } + } + + return { + get el() { + return el; + }, + remove() { + WINDOW.removeEventListener('resize', resizeCanvas); + }, + show(src: string) { + editor = new ImageEditor({ canvas, image: srcToImage(src), onLoad: resizeCanvas }); + editor.tool = new Arrow(); + editor.color = '#ff7738'; + editor.strokeSize = 6; + el.setAttribute('aria-hidden', 'false'); + }, + hide() { + el.setAttribute('aria-hidden', 'true'); + }, + }; +} diff --git a/packages/feedback/src/widget/screenshot/createScreenshotWidget.ts b/packages/feedback/src/widget/screenshot/createScreenshotWidget.ts new file mode 100644 index 000000000000..3e0c61ada093 --- /dev/null +++ b/packages/feedback/src/widget/screenshot/createScreenshotWidget.ts @@ -0,0 +1,182 @@ +import { WINDOW } from '@sentry/browser'; + +import type { Screenshot } from '../../types'; +import { createElement } from '../util/createElement'; +import { ScreenshotForm } from './Form'; +import { createScreenshotStyles } from './Screenshot.css'; +import { ScreenshotAnnotator } from './ScreenshotAnnotator'; +// import type { Rect } from './ScreenshotEditor'; +import { ScreenshotEditor } from './ScreenshotEditor'; + +async function blobToUint8Array(blob: Blob): Promise { + const blobData = await blob.arrayBuffer(); + return new Uint8Array(blobData); +} + +function blobToBase64(blob: Blob | null): Promise { + return new Promise((resolve, _) => { + if (!blob) { + resolve(''); + return; + } + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.readAsDataURL(blob); + }); +} + +/** + * + */ +export function createScreenshotWidget(): { + dialogEl: HTMLDivElement; + formEl: HTMLDivElement | null; + ScreenshotForm: ReturnType; + ScreenshotStyles: ReturnType; + getData: () => Promise; +} { + // let selection: Rect | undefined; + let screenshot: Blob | null = null; + let screenshotCutout: Blob | null = null; + let screenshotPreview: string = ''; + let screenshotCutoutPreview: string = ''; + + /** + * + */ + function setScreenshot(newScreenshot: Blob | null): void { + screenshot = newScreenshot; + } + /** + * + */ + function setScreenshotCutout(newCutout: Blob | null): void { + screenshotCutout = newCutout; + } + /** + * + */ + function setScreenshotPreview(newPreviewBase64: string | null): void { + screenshotPreview = newPreviewBase64 || ''; + } + /** + * + */ + function setScreenshotCutoutPreview(newPreviewBase64: string | null): void { + screenshotCutoutPreview = newPreviewBase64 || ''; + } + + /** + * + */ + function setEdit(type: 'cutout' | 'screenshot', editing: boolean): void { + const target = type === 'cutout' ? cutoutAnnotator : screenshotAnnotator; + if (editing) { + const src = type === 'cutout' ? screenshotCutoutPreview : screenshotPreview; + target.show(src); + } else { + target.hide(); + } + } + + /** + * + */ + async function handleEditorSubmit( + newScreenshot: Blob | null, + newCutout?: Blob | null, + // newSelection?: Rect, + ): Promise { + setScreenshot(newScreenshot); + setScreenshotCutout(newCutout || null); + setScreenshotPreview(await blobToBase64(newScreenshot)); + setScreenshotCutoutPreview((newCutout && (await blobToBase64(newCutout))) || ''); + screenshotForm.setFormPreview(await blobToBase64(newScreenshot)); + screenshotForm.setFormCutoutPreview((newCutout && (await blobToBase64(newCutout))) || ''); + // selection = newSelection; + + screenshotEditor.hide(); + } + + const screenshotEditor = ScreenshotEditor({ onSubmit: handleEditorSubmit }); + + const screenshotForm = ScreenshotForm({ + onEditCutout: () => setEdit('cutout', true), + onEditScreenshot: () => setEdit('screenshot', true), + onTakeScreenshot: (image: string) => { + setScreenshotPreview(image); + screenshotEditor.show(image); + }, + }); + + const screenshotAnnotator = ScreenshotAnnotator({ + onSubmit: async newScreenshot => { + setScreenshot(newScreenshot); + setScreenshotPreview(await blobToBase64(newScreenshot)); + screenshotForm.setFormPreview(await blobToBase64(newScreenshot)); + setEdit('screenshot', false); + }, + onCancel: () => { + setEdit('screenshot', false); + // setIsEditScreenshotOpen(false); + }, + }); + + const cutoutAnnotator = ScreenshotAnnotator({ + onSubmit: async newCutout => { + setScreenshotCutout(newCutout); + setScreenshotCutoutPreview(await blobToBase64(newCutout)); + screenshotForm.setFormCutoutPreview((newCutout && (await blobToBase64(newCutout))) || ''); + setEdit('cutout', false); + }, + onCancel: () => { + setEdit('cutout', false); + }, + }); + + const dialogEl = createElement('div', {}, [ + screenshotPreview && !screenshot && screenshotEditor.el, + screenshotEditor.el, + screenshotAnnotator.el, + cutoutAnnotator.el, + ]); + + return { + dialogEl, + formEl: screenshotForm.el, + ScreenshotForm: screenshotForm, + ScreenshotStyles: createScreenshotStyles(WINDOW.document), + // processScreenshot: async (scope: Scope) => { + // + // if (imageData) { + // scope.addAttachment(); + // console.log('adding attachments to scope'); + // } + // + // if (imageCutoutData) { + // scope.addAttachment(); + // console.log('adding attachments to scope'); + // } + // }, + async getData() { + const imageData = screenshot && (await blobToUint8Array(screenshot)); + const imageCutoutData = screenshotCutout && (await blobToUint8Array(screenshotCutout)); + const data = []; + if (imageData) { + data.push({ + filename: 'screenshot.png', + data: imageData, + contentType: 'image/png', + }); + } + if (imageCutoutData) { + data.push({ + filename: 'screenshot-cutout.png', + data: imageCutoutData, + contentType: 'image/png', + }); + } + return data; + }, + }; +} diff --git a/packages/feedback/src/widget/screenshot/form.ts b/packages/feedback/src/widget/screenshot/form.ts new file mode 100644 index 000000000000..b3fe50b848a6 --- /dev/null +++ b/packages/feedback/src/widget/screenshot/form.ts @@ -0,0 +1,118 @@ +import { logger } from '@sentry/utils'; + +import type { FeedbackComponent } from '../../types'; +import { takeScreenshot } from '../../util/takeScreenshot'; +import { createElement } from '../util/createElement'; + +interface ScreenshotFormProps { + onEditScreenshot: () => void; + onEditCutout: () => void; + onTakeScreenshot: (image: string) => void; +} + +export interface ScreenshotFormComponent extends FeedbackComponent { + setFormPreview: (image: string) => void; + setFormCutoutPreview: (image: string) => void; +} + +/** + * Component for taking screenshots in feedback dialog + */ +export function ScreenshotForm({ + onEditCutout, + onEditScreenshot, + onTakeScreenshot, +}: ScreenshotFormProps): ScreenshotFormComponent { + const handleAddScreenshot = async (): Promise => { + try { + const image = await takeScreenshot(); + onTakeScreenshot(image); + setScreenshotPreview(image); + } catch (e) { + __DEBUG_BUILD__ && logger.error(e); + } + }; + + const addScreenshotButton = createElement( + 'button', + { + className: 'btn btn--default', + type: 'button', + 'aria-hidden': 'false', + onClick: handleAddScreenshot, + }, + 'Add Screenshot', + ); + const imageEl = createElement('img', { className: 'screenshot-preview__image' }); + const cutoutImageEl = createElement('img', { + className: 'screenshot-preview__image screenshot-preview__image__cutout', + // src: screenshotCutoutPreview, + }); + const editScreenshotButton = createElement( + 'button', + { + className: 'screenshot-preview', + type: 'button', + 'aria-label': 'Edit screenshot', + 'aria-hidden': 'true', + onClick: onEditScreenshot, + }, + [imageEl], + ); + const editCutoutButton = createElement( + 'button', + { + className: 'screenshot-preview', + type: 'button', + 'aria-label': 'Edit screenshot cutout', + 'aria-hidden': 'true', + onClick: onEditCutout, + }, + cutoutImageEl, + ); + const screenshotPreviewWrapper = createElement( + 'div', + { className: 'screenshot-preview__wrapper', 'aria-hidden': 'true' }, + [editScreenshotButton, editCutoutButton], + ); + + function setScreenshotPreview(image: string): void { + if (!image) { + screenshotPreviewWrapper.setAttribute('aria-hidden', 'true'); + editScreenshotButton.setAttribute('aria-hidden', 'false'); + addScreenshotButton.setAttribute('aria-hidden', 'false'); + } + + imageEl.setAttribute('src', image); + screenshotPreviewWrapper.setAttribute('aria-hidden', 'false'); + editScreenshotButton.setAttribute('aria-hidden', 'false'); + addScreenshotButton.setAttribute('aria-hidden', 'true'); + } + + function setScreenshotCutoutPreview(image: string): void { + if (!image) { + screenshotPreviewWrapper.setAttribute('aria-hidden', 'true'); + addScreenshotButton.setAttribute('aria-hidden', 'false'); + editCutoutButton.setAttribute('aria-hidden', 'true'); + } + + cutoutImageEl.setAttribute('src', image); + screenshotPreviewWrapper.setAttribute('aria-hidden', 'false'); + editCutoutButton.setAttribute('aria-hidden', 'false'); + addScreenshotButton.setAttribute('aria-hidden', 'true'); + } + + const el = createElement('div', { className: 'screenshot-editor__row' }, [ + createElement('label', {}, 'Screenshot'), + screenshotPreviewWrapper, + addScreenshotButton, + ]); + + return { + get el() { + return el; + }, + setFormPreview: setScreenshotPreview, + setFormCutoutPreview: setScreenshotCutoutPreview, + }; +} diff --git a/packages/feedback/src/widget/screenshot/icons.ts b/packages/feedback/src/widget/screenshot/icons.ts new file mode 100644 index 000000000000..5e54543b0e44 --- /dev/null +++ b/packages/feedback/src/widget/screenshot/icons.ts @@ -0,0 +1,181 @@ +import { WINDOW } from '@sentry/browser'; + +const XMLNS = 'http://www.w3.org/2000/svg'; + +const createElementNS = ( + tagName: K, + attributes: { [key: string]: string | boolean | EventListenerOrEventListenerObject } | null, + ...children: any +): SVGElementTagNameMap[K] => { + const doc = WINDOW.document; + + const el = doc.createElementNS(XMLNS, tagName); + + if (attributes) { + Object.entries(attributes).forEach(([attribute, attributeValue]) => { + if (attribute === 'className' && typeof attributeValue === 'string') { + // JSX does not allow class as a valid name + el.setAttributeNS(null, 'class', attributeValue); + } else if (typeof attributeValue === 'boolean' && attributeValue) { + el.setAttributeNS(null, attribute, ''); + } else if (typeof attributeValue === 'string') { + el.setAttributeNS(null, attribute, attributeValue); + } else if (attribute.startsWith('on') && typeof attributeValue === 'function') { + el.addEventListener(attribute.substring(2).toLowerCase(), attributeValue); + } + }); + } + + for (const child of children) { + appendChild(el, child); + } + + return el; +}; + +function appendChild(parent: Node, child: any): void { + if (typeof child === 'undefined' || child === null) { + return; + } + + if (Array.isArray(child)) { + for (const value of child) { + appendChild(parent, value); + } + } else if (child === false) { + // do nothing if child evaluated to false + } else { + parent.appendChild(child); + } +} + +export interface IconComponent { + el: SVGSVGElement; +} + +/** + * + */ +export function PenIcon(): IconComponent { + return { + get el() { + return createElementNS( + 'svg', + { + width: '16', + height: '16', + viewBox: '0 0 16 16', + fill: 'none', + }, + createElementNS('path', { + d: 'M8.5 12L12 8.5L14 11L11 14L8.5 12Z', + stroke: 'currentColor', + 'stroke-width': '1.5', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + }), + createElementNS('path', { + d: 'M12 8.5L11 3.5L2 2L3.5 11L8.5 12L12 8.5Z', + stroke: 'currentColor', + 'stroke-width': '1.5', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + }), + createElementNS('path', { + d: 'M2 2L7.5 7.5', + stroke: 'currentColor', + 'stroke-width': '1.5', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + }), + ); + }, + }; +} + +/** + * + */ +export function RectangleIcon(): IconComponent { + return { + get el() { + return createElementNS( + 'svg', + { + width: '16', + height: '16', + viewBox: '0 0 16 16', + fill: 'none', + }, + createElementNS('rect', { + x: '2.5', + y: '2.5', + width: '11', + height: '11', + rx: '2', + stroke: 'currentColor', + 'stroke-width': '1.5', + }), + ); + }, + }; +} + +/** + * + */ +export function ArrowIcon(): IconComponent { + return { + get el() { + return createElementNS( + 'svg', + { + width: '16', + height: '16', + viewBox: '0 0 16 16', + fill: 'none', + }, + createElementNS('path', { + d: 'M2.5 2.5L13 13', + stroke: 'currentColor', + 'stroke-width': '1.5', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + }), + createElementNS('path', { + d: 'M8.5 2.5H2.5L2.5 8.5', + stroke: 'currentColor', + 'stroke-width': '1.5', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + }), + ); + }, + }; +} + +/** + * + */ +export function HandIcon(): IconComponent { + return { + get el() { + return createElementNS( + 'svg', + { + width: '16', + height: '16', + viewBox: '0 0 16 16', + fill: 'none', + }, + createElementNS('path', { + d: 'M2 2L6.5 14.5L8.5 8.5L14.5 6.5L2 2Z', + stroke: 'currentColor', + 'stroke-width': '1.5', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + }), + ); + }, + }; +} diff --git a/packages/feedback/src/widget/screenshot/screenshotEditor.ts b/packages/feedback/src/widget/screenshot/screenshotEditor.ts new file mode 100644 index 000000000000..8ef1b75ed533 --- /dev/null +++ b/packages/feedback/src/widget/screenshot/screenshotEditor.ts @@ -0,0 +1,222 @@ +import { WINDOW } from '@sentry/browser'; + +import type { FeedbackComponent } from '../../types'; +import { createElement } from '../util/createElement'; +import { ScreenshotEditorHelp } from './screenshotEditorHelp'; + +export interface Rect { + height: number; + width: number; + x: number; + y: number; +} + +interface Point { + x: number; + y: number; +} + +interface ScreenshotEditorProps { + onSubmit: (screenshot: Blob | null, cutout?: Blob | null, selection?: Rect) => void; +} + +interface ScreenshotEditorComponent extends FeedbackComponent { + remove: () => void; + show: (dataUrl: string) => void; + hide: () => void; +} + +const getCanvasRenderSize = (width: number, height: number) => { + const maxWidth = WINDOW.innerWidth; + const maxHeight = WINDOW.innerHeight; + + if (width > maxWidth) { + height = (maxWidth / width) * height; + width = maxWidth; + } + + if (height > maxHeight) { + width = (maxHeight / height) * width; + height = maxHeight; + } + + return { width, height }; +}; + +const canvasToBlob = (canvas: HTMLCanvasElement): Promise => { + return new Promise(resolve => { + canvas.toBlob(blob => { + resolve(blob); + }); + }); +}; + +const constructRect = (start: Point, end: Point): Rect => { + return { + x: Math.min(start.x, end.x), + y: Math.min(start.y, end.y), + width: Math.abs(start.x - end.x), + height: Math.abs(start.y - end.y), + }; +}; + +/** + * + */ +export function ScreenshotEditor({ onSubmit }: ScreenshotEditorProps): ScreenshotEditorComponent { + let currentRatio = 1; + const canvas = createElement('canvas', { className: 'screenshot-editor' }); + const screenshotEditorHelp = ScreenshotEditorHelp(); + const el = createElement( + 'div', + { className: 'screenshot-editor__container', 'aria-hidden': 'true', onClick: (e: Event) => e.stopPropagation() }, + canvas, + screenshotEditorHelp.el, + ); + + const ctx = canvas.getContext('2d'); + const img = new Image(); + const rectStart: { x: number; y: number } = { x: 0, y: 0 }; + const rectEnd: { x: number; y: number } = { x: 0, y: 0 }; + let isDragging = false; + + /** + * + */ + function setCanvasSize(): void { + const renderSize = getCanvasRenderSize(img.width, img.height); + canvas.style.width = `${renderSize.width}px`; + canvas.style.height = `${renderSize.height}px`; + canvas.style.top = `${(WINDOW.innerHeight - renderSize.height) / 2}px`; + canvas.style.left = `${(WINDOW.innerWidth - renderSize.width) / 2}px`; + // store it so we can translate the selection + currentRatio = renderSize.width / img.width; + } + + /** + * + */ + function refreshCanvas(): void { + if (!ctx) { + return; + } + + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(img, 0, 0); + + if (!isDragging) { + return; + } + + const rect = constructRect(rectStart, rectEnd); + + // draw gray overlay around the selectio + ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'; + ctx.fillRect(0, 0, canvas.width, rect.y); + ctx.fillRect(0, rect.y, rect.x, rect.height); + ctx.fillRect(rect.x + rect.width, rect.y, canvas.width, rect.height); + ctx.fillRect(0, rect.y + rect.height, canvas.width, canvas.height); + + // draw selection border + ctx.strokeStyle = '#79628c'; + ctx.lineWidth = 6; + ctx.strokeRect(rect.x, rect.y, rect.width, rect.height); + } + + /** + * + */ + async function submit(rect?: Rect): Promise { + const imageBlob = await canvasToBlob(canvas); + if (!rect) { + onSubmit(imageBlob); + return; + } + const cutoutCanvas = WINDOW.document.createElement('canvas'); + cutoutCanvas.width = rect.width; + cutoutCanvas.height = rect.height; + const cutoutCtx = cutoutCanvas.getContext('2d'); + cutoutCtx && cutoutCtx.drawImage(canvas, rect.x, rect.y, rect.width, rect.height, 0, 0, rect.width, rect.height); + const cutoutBlob = await canvasToBlob(cutoutCanvas); + onSubmit(imageBlob, cutoutBlob, rect); + } + + /** + * + */ + function handleMouseDown(e: MouseEvent): void { + rectStart.x = Math.floor(e.offsetX / currentRatio); + rectStart.y = Math.floor(e.offsetY / currentRatio); + isDragging = true; + screenshotEditorHelp.setHidden(true); + } + /** + * + */ + function handleMouseMove(e: MouseEvent): void { + rectEnd.x = Math.floor(e.offsetX / currentRatio); + rectEnd.y = Math.floor(e.offsetY / currentRatio); + refreshCanvas(); + } + /** + * + */ + function handleMouseUp(): void { + isDragging = false; + screenshotEditorHelp.setHidden(false); + if (rectStart.x - rectEnd.x === 0 && rectStart.y - rectEnd.y === 0) { + // no selection + refreshCanvas(); + return; + } + void submit(constructRect(rectStart, rectEnd)); + } + + /** + * + */ + function handleEnterKey(e: KeyboardEvent): void { + if (e.key === 'Enter') { + void submit(); + } + } + + img.onload = () => { + canvas.width = img.width; + canvas.height = img.height; + setCanvasSize(); + ctx && ctx.drawImage(img, 0, 0); + }; + + return { + get el() { + return el; + }, + remove() { + WINDOW.removeEventListener('resize', setCanvasSize); + canvas.removeEventListener('mousedown', handleMouseDown); + canvas.removeEventListener('mousemove', handleMouseMove); + canvas.removeEventListener('mouseup', handleMouseUp); + WINDOW.removeEventListener('keydown', handleEnterKey); + el.remove(); + }, + show(dataUrl: string) { + el.setAttribute('aria-hidden', 'false'); + img.src = dataUrl; + + WINDOW.addEventListener('resize', setCanvasSize, { passive: true }); + canvas.addEventListener('mousedown', handleMouseDown); + canvas.addEventListener('mousemove', handleMouseMove); + canvas.addEventListener('mouseup', handleMouseUp); + WINDOW.addEventListener('keydown', handleEnterKey); + }, + hide() { + el.setAttribute('aria-hidden', 'true'); + WINDOW.removeEventListener('resize', setCanvasSize); + canvas.removeEventListener('mousedown', handleMouseDown); + canvas.removeEventListener('mousemove', handleMouseMove); + canvas.removeEventListener('mouseup', handleMouseUp); + WINDOW.removeEventListener('keydown', handleEnterKey); + }, + }; +} diff --git a/packages/feedback/src/widget/screenshot/screenshotEditorHelp.ts b/packages/feedback/src/widget/screenshot/screenshotEditorHelp.ts new file mode 100644 index 000000000000..86518e411133 --- /dev/null +++ b/packages/feedback/src/widget/screenshot/screenshotEditorHelp.ts @@ -0,0 +1,62 @@ +import { WINDOW } from '@sentry/browser'; + +import type { FeedbackComponent } from '../../types'; +import { createElement } from '../util/createElement'; + +interface ScreenshotEditorHelpComponent extends FeedbackComponent { + setHidden: (hidden: boolean) => void; + remove: () => void; +} + +/** + * + */ +export function ScreenshotEditorHelp({ hide }: { hide?: boolean } = {}): ScreenshotEditorHelpComponent { + const contentEl = createElement( + 'div', + { className: 'screenshot-editor__help__content' }, + 'Mark the problem on the screen (press "Enter" to skip)', + ); + const el = createElement('div', { className: 'screenshot-editor__help', ['aria-hidden']: Boolean(hide) }, contentEl); + let boundingRect = contentEl.getBoundingClientRect(); + + function setHidden(hidden: boolean): void { + el.setAttribute('aria-hidden', `${hidden}`); + } + + const handleMouseMove = (e: MouseEvent): void => { + const { clientX, clientY } = e; + const { left, bottom, right } = boundingRect; + const threshold = 50; + const isNearContent = clientX > left - threshold && clientX < right + threshold && clientY < bottom + threshold; + + if (isNearContent) { + setHidden(true); + } else { + setHidden(false); + } + }; + + /** + * Update boundingRect when resized + */ + function handleResize(): void { + boundingRect = contentEl.getBoundingClientRect(); + } + + WINDOW.addEventListener('resize', handleResize); + WINDOW.addEventListener('mousemove', handleMouseMove); + + return { + get el() { + return el; + }, + + setHidden, + + remove() { + WINDOW.removeEventListener('resize', handleResize); + WINDOW.removeEventListener('mousemove', handleMouseMove); + }, + }; +}