From d4780260c6238b3ed111a3e035e880d55a4bfb42 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Thu, 9 Nov 2023 15:24:03 -0500 Subject: [PATCH 1/3] wip --- .../feedback/src/util/imageEditor/index.ts | 373 ++++++++++++++++++ .../feedback/src/util/imageEditor/tool.ts | 279 +++++++++++++ .../feedback/src/util/imageEditor/types.ts | 35 ++ .../feedback/src/util/imageEditor/utils.ts | 168 ++++++++ packages/feedback/src/util/takeScreenshot.ts | 38 ++ packages/feedback/src/widget/Dialog.ts | 5 + packages/feedback/src/widget/Form.ts | 7 + .../src/widget/screenshot/ImageEditor.ts | 179 +++++++++ .../src/widget/screenshot/Screenshot.css.ts | 203 ++++++++++ .../screenshot/createScreenshotWidget.ts | 142 +++++++ .../feedback/src/widget/screenshot/form.ts | 136 +++++++ .../feedback/src/widget/screenshot/icons.ts | 169 ++++++++ .../src/widget/screenshot/screenshotEditor.ts | 180 +++++++++ .../widget/screenshot/screenshotEditorHelp.ts | 61 +++ 14 files changed, 1975 insertions(+) create mode 100644 packages/feedback/src/util/imageEditor/index.ts create mode 100644 packages/feedback/src/util/imageEditor/tool.ts create mode 100644 packages/feedback/src/util/imageEditor/types.ts create mode 100644 packages/feedback/src/util/imageEditor/utils.ts create mode 100644 packages/feedback/src/util/takeScreenshot.ts create mode 100644 packages/feedback/src/widget/screenshot/ImageEditor.ts create mode 100644 packages/feedback/src/widget/screenshot/Screenshot.css.ts create mode 100644 packages/feedback/src/widget/screenshot/createScreenshotWidget.ts create mode 100644 packages/feedback/src/widget/screenshot/form.ts create mode 100644 packages/feedback/src/widget/screenshot/icons.ts create mode 100644 packages/feedback/src/widget/screenshot/screenshotEditor.ts create mode 100644 packages/feedback/src/widget/screenshot/screenshotEditorHelp.ts diff --git a/packages/feedback/src/util/imageEditor/index.ts b/packages/feedback/src/util/imageEditor/index.ts new file mode 100644 index 000000000000..02a13592dc6e --- /dev/null +++ b/packages/feedback/src/util/imageEditor/index.ts @@ -0,0 +1,373 @@ +import { WINDOW } from '@sentry/browser'; +import { 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; + 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(); + }); + } + + 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 = () => { + 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 => { + drawing.drawToCanvas(this.ctx, drawing.id === this.selectedDrawingId); + }); + if (this._tool?.isDrawing) { + const drawing = this._tool.getDrawingBuffer(); + if (drawing) { + 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 => d.isInPath(this.ctx, point)); + this.selectedDrawingId = drawing?.id; + 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.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.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..6577beaf93f5 --- /dev/null +++ b/packages/feedback/src/util/imageEditor/tool.ts @@ -0,0 +1,279 @@ +import { 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.draw(point); + } + endDrawing(point: IPoint) { + if (!this.isDrawing) { + throw new Error('Call startDrawing before calling endDrawing'); + } + 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 = { x: 0, y: 0 }; + 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); + } + + 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; + + 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..dac903d9476d --- /dev/null +++ b/packages/feedback/src/util/imageEditor/utils.ts @@ -0,0 +1,168 @@ +import { 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/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..ca559d33dee7 100644 --- a/packages/feedback/src/widget/Dialog.ts +++ b/packages/feedback/src/widget/Dialog.ts @@ -2,6 +2,7 @@ import type { FeedbackComponent, FeedbackInternalOptions } 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 @@ -94,11 +95,14 @@ export function Dialog({ return (el && el.open === true) || false; } + const screenshot = createScreenshotWidget(); + const { el: formEl, showError, hideError, } = Form({ + screenshotForm: screenshot.ScreenshotForm, showEmail, showName, isAnonymous, @@ -145,6 +149,7 @@ export function Dialog({ ), formEl, ), + screenshot.ScreenshotDialog.el, ); return { diff --git a/packages/feedback/src/widget/Form.ts b/packages/feedback/src/widget/Form.ts index 74ff06c015b9..33a202a27fe7 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 { 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/screenshot/ImageEditor.ts b/packages/feedback/src/widget/screenshot/ImageEditor.ts new file mode 100644 index 000000000000..4f0888ccdc80 --- /dev/null +++ b/packages/feedback/src/widget/screenshot/ImageEditor.ts @@ -0,0 +1,179 @@ +// import React, {ComponentType, useCallback, useEffect, useMemo, useState} from 'react'; +// import styled from '@emotion/styled'; +// +import { WINDOW } from '@sentry/browser'; +import { createElement } from '../util/createElement'; +// import { ToolKey, Tools, useImageEditor } from './hooks/useImageEditor'; +import { ArrowIcon, HandIcon, IconComponent, 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 ImageEditorWrapperProps { + onCancel: () => void; + onSubmit: (screenshot: Blob) => void; + src: string; +} + +// const ColorDisplay = styled.div<{ color: string }>` +// ${({ color }) => `background-color: ${color};`} +// `; +// +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(); +} + +export function ImageEditorWrapper({ src, onCancel, onSubmit }: ImageEditorWrapperProps) { + let selectedColor = ''; + + 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`; + }; + + const image = () => srcToImage(src); + // const { selectedTool, setSelectedTool, selectedColor, setSelectedColor, getBlob } = useImageEditor({ + // canvas, + // image, + // onLoad: resizeCanvas, + // }); + + resizeCanvas(); + window.addEventListener('resize', resizeCanvas); + + function handleSubmit(): void {} + function setSelectedColor(color: string) {} + function setSelectedTool(tool: string) {} + + const canvas = createElement('canvas', { className: '.image-editor__canvas' }); + const wrapper = createElement('div', { className: 'image-editor__canvas__wrapper' }, canvas); + const toolbarGroupTools = createElement( + 'div', + { className: 'image-editor__toolbar__group' }, + Tools.map(tool => + createElement( + 'button', + { className: 'image-editor__tool-button', onClick: () => setSelectedTool(tool) }, + ToolIcon({ tool }).el, + ), + ), + ); + const colorDisplay = createElement('div', { + className: 'image-editor__color-display', + style: `background-color: ${selectedColor}`, + }); + + const colorInput = createElement('input', { + type: 'color', + value: selectedColor, + onChange: (e: Event): void => { + e.target && setSelectedColor((e.target as HTMLInputElement).value); + }, + }); + + const colorInputLabel = createElement( + 'label', + { + className: 'iamge-editor__color-input', + }, + colorDisplay, + colorInput, + ); + const toolbarGroupColor = createElement('div', { className: 'image-editor__toolbar__group' }, colorInputLabel); + const toolbar = createElement( + 'div', + { className: 'image-editor__canvas__toolbar' }, + createElement('button', { className: 'image-editor__canvas__cancel', onClick: onCancel }, 'Cancel'), + createElement('div', { className: 'image-editor__spacer' }), + toolbarGroupTools, + toolbarGroupColor, + createElement('div', { className: 'image-editor__spacer' }), + createElement('button', { className: 'image-editor__canvas__submit', onClick: handleSubmit }, 'Save'), + ); + + const imageEditor = createElement('div', { className: 'image-editor__container' }, wrapper, toolbar); + + return { + get el() { + return imageEditor; + }, + remove() { + WINDOW.removeEventListener('resize', resizeCanvas); + }, + }; + + // return ( + // + // + // + // + // + // onCancel()}>Cancel + // + // + // {Tools.map(tool => ( + // setSelectedTool(tool)}> + // + // + // ))} + // + // + // + // + // setSelectedColor(e.target.value)} /> + // + // + // + // onSubmit(await getBlob())}>Save + // + // + // ); +} 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..55ca7fdd0a77 --- /dev/null +++ b/packages/feedback/src/widget/screenshot/Screenshot.css.ts @@ -0,0 +1,203 @@ +export function createScreenshotStyles(d: Document): HTMLStyleElement { + const style = d.createElement('style'); + + style.textContent = ` +.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__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); +} + +.image-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 + ); +} + +.image-editor__canvas { + cursor: crosshair; + max-width: 100vw; + max-height: 100vh; +} + +.image-editor__canvas__wrapper { + position: relative; + width: 100%; + margin-top: 32px; + height: calc(100% - 96px); + display: flex; + align-items: center; + justify-content: center; +} + +.image-editor__toolbar { + position: absolute; + width: 100%; + bottom: 0px; + padding: 12px 16px; + display: flex; + gap: 12px; + flex-direction: row; + justify-content: center; +} + +.image-editor__toolbar__group { + display: flex; + flex-direction: row; + height: 42px; + 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); +} + +.image-editor__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); + } +} + +.image-editor__tool-button--active { + background-color: rgba(108, 95, 199, 1) !important; + color: white; +} + +.image-editor__tool-icon { + +} + +.image-editor__spacer { + flex: 1; +} + +.image-editor__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; + } + +} + +.image-editor__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); + } + +} + +.image-editor__color-display { + width: 16px; + height: 16px; + border-radius: 4px; + +} +.image-editor__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/createScreenshotWidget.ts b/packages/feedback/src/widget/screenshot/createScreenshotWidget.ts new file mode 100644 index 000000000000..6f3d7ef66d88 --- /dev/null +++ b/packages/feedback/src/widget/screenshot/createScreenshotWidget.ts @@ -0,0 +1,142 @@ +import { WINDOW } from '@sentry/browser'; +import { FeedbackComponent } from '../../types'; +import { createElement } from '../util/createElement'; +import { ScreenshotForm } from './form'; +import { Rect, ScreenshotEditor } from './screenshotEditor'; + +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(): { + ScreenshotForm: ReturnType; + ScreenshotDialog: ReturnType; +} { + 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) {} + + async function handleEditorSubmit( + newScreenshot: Blob | null, + cutout?: Blob | null, + newSelection?: Rect, + ): Promise { + setScreenshot(newScreenshot); + setScreenshotCutout(cutout || null); + setScreenshotPreview(await blobToBase64(newScreenshot)); + setScreenshotCutoutPreview((cutout && (await blobToBase64(cutout))) || ''); + selection = newSelection; + } + + const screenshotDialog = ScreenshotDialog({ + screenshot, + screenshotPreview, + screenshotCutout, + screenshotCutoutPreview, + onSubmit: handleEditorSubmit, + }); + + const screenshotForm = ScreenshotForm({ + screenshotPreview, + screenshotCutout, + screenshotCutoutPreview, + onEditCutout: () => setEdit('cutout', true), + onEditScreenshot: () => setEdit('screenshot', true), + onTakeScreenshot: (image: string) => { + setScreenshotPreview(image); + }, + }); + + return { + ScreenshotForm: screenshotForm, + ScreenshotDialog: screenshotDialog, + }; +} + +interface ScreenshotDialogProps { + screenshot: Blob | null; + /** + * base64 of screenshot preview + */ + screenshotPreview: string; + screenshotCutout: Blob | null; + /** + * base64 of `screenshotCutout` + */ + screenshotCutoutPreview: string; + onSubmit: (screenshot: Blob | null, cutout?: Blob | null, selection?: Rect) => void; +} + +function ScreenshotDialog({ + screenshot, + screenshotPreview, + screenshotCutoutPreview, + onSubmit, +}: ScreenshotDialogProps): FeedbackComponent { + const fragment = WINDOW.document.createDocumentFragment(); + + const screenshotEditor = ScreenshotEditor({ dataUrl: screenshotPreview, onSubmit }); + + const screenshotEditorWrapperEl = createElement('div', { + className: 'screenshot-image-editor__wrapper', + src: screenshotPreview, + onSubmit: async newScreenshot => { + // setScreenshot(newScreenshot); + // setScreenshotPreview(await blobToBase64(newScreenshot)); + // setIsEditScreenshotOpen(false); + }, + onCancel: () => { + // setIsEditScreenshotOpen(false); + }, + }); + + const cutoutEditorEl = createElement('div', { + className: 'screenshot-image-editor__wrapper', + src: screenshotCutoutPreview, + onSubmit: async newCutout => { + // setScreenshotCutout(newCutout); + // setScreenshotCutoutPreview(await blobToBase64(newCutout)); + // setIsEditCutoutOpen(false); + }, + onCancel: () => { + // setIsEditCutoutOpen(false); + }, + }); + + const el = createElement('div', {}, [ + screenshotPreview && !screenshot && screenshotEditor.el, + screenshotEditor.el, + cutoutEditorEl, + ]); + + return { + get el() { + return el; + }, + }; +} diff --git a/packages/feedback/src/widget/screenshot/form.ts b/packages/feedback/src/widget/screenshot/form.ts new file mode 100644 index 000000000000..f013cdf4e05b --- /dev/null +++ b/packages/feedback/src/widget/screenshot/form.ts @@ -0,0 +1,136 @@ +import { logger } from '@sentry/utils'; +import { FeedbackComponent } from '../../types'; +import { takeScreenshot } from '../../util/takeScreenshot'; +import { createElement } from '../util/createElement'; + +interface ScreenshotFormProps { + /** + * base64 of screenshot preview + */ + screenshotPreview: string; + screenshotCutout: Blob | null; + /** + * base64 of `screenshotCutout` + */ + screenshotCutoutPreview: string; + + 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({ + screenshotPreview, + screenshotCutout, + screenshotCutoutPreview, + 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', + { type: 'button', 'aria-hidden': 'false', onClick: handleAddScreenshot }, + 'Add Screenshot', + ); + const imageEl = createElement('img', { className: 'screenshot-preview__image', src: screenshotPreview }); + 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__cutout', + type: 'button', + 'aria-label': 'Edit screenshot cutout', + 'aria-hidden': 'true', + onClick: onEditCutout, + }, + cutoutImageEl, + ); + const screenshotPreviewWrapper = createElement('div', { className: 'screenshot-wrapper', 'aria-hidden': 'true' }, [ + editScreenshotButton, + editCutoutButton, + ]); + + function setScreenshotPreview(image: string): void { + if (!image) { + screenshotPreviewWrapper.setAttribute('aria-hidden', 'true'); + 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 { + cutoutImageEl.setAttribute('src', image); + } + + const el = createElement('div', {}, [ + createElement('label', {}, 'Screenshot'), + screenshotPreviewWrapper, + addScreenshotButton, + ]); + // + // {screenshotPreview ? ( + // + // setIsEditScreenshotOpen(true)} + // > + // + // + // {screenshotCutout && ( + // setIsEditCutoutOpen(true)} + // > + // + // + // )} + // + // ) : ( + // + // Add Screenshot + // + // )} + 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..7da169c03566 --- /dev/null +++ b/packages/feedback/src/widget/screenshot/icons.ts @@ -0,0 +1,169 @@ +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(XMLNS, 'class', attributeValue); + } else if (typeof attributeValue === 'boolean' && attributeValue) { + el.setAttributeNS(XMLNS, attribute, ''); + } else if (typeof attributeValue === 'string') { + el.setAttributeNS(XMLNS, 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', + strokeWidth: '1.5', + strokeLinecap: 'round', + strokeLinejoin: 'round', + }), + createElementNS('path', { + d: 'M12 8.5L11 3.5L2 2L3.5 11L8.5 12L12 8.5Z', + stroke: 'currentColor', + strokeWidth: '1.5', + strokeLinecap: 'round', + strokeLinejoin: 'round', + }), + createElementNS('path', { + d: 'M2 2L7.5 7.5', + stroke: 'currentColor', + strokeWidth: '1.5', + strokeLinecap: 'round', + strokeLinejoin: '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', + strokeWidth: '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', + strokeWidth: '1.5', + strokeLinecap: 'round', + strokeLinejoin: 'round', + }), + createElementNS('path', { + d: 'M8.5 2.5H2.5L2.5 8.5', + stroke: 'currentColor', + strokeWidth: '1.5', + strokeLinecap: 'round', + strokeLinejoin: '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', + strokeWidth: '1.5', + strokeLinecap: 'round', + strokeLinejoin: '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..92347fb89b3b --- /dev/null +++ b/packages/feedback/src/widget/screenshot/screenshotEditor.ts @@ -0,0 +1,180 @@ +import { WINDOW } from '@sentry/browser'; +import { createElement } from '../util/createElement'; +import { ScreenshotEditorHelp } from './screenshotEditorHelp'; + +export interface Rect { + height: number; + width: number; + x: number; + y: number; +} +interface ScreenshotEditorProps { + dataUrl: string; + onSubmit: (screenshot: Blob | null, cutout?: Blob | null, selection?: Rect) => 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); + }); + }); +}; +interface Point { + x: number; + y: number; +} + +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({ dataUrl, onSubmit }: ScreenshotEditorProps) { + let currentRatio = 1; + const canvas = createElement('canvas', { className: 'screenshot-editor' }); + const screenshotEditorHelp = ScreenshotEditorHelp(); + const el = createElement('div', { className: 'screenshot-editor__container' }, 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); + }; + + 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); + + 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(); + }, + }; + // ( + // + // + // + // + // ); +} diff --git a/packages/feedback/src/widget/screenshot/screenshotEditorHelp.ts b/packages/feedback/src/widget/screenshot/screenshotEditorHelp.ts new file mode 100644 index 000000000000..029abb695ee4 --- /dev/null +++ b/packages/feedback/src/widget/screenshot/screenshotEditorHelp.ts @@ -0,0 +1,61 @@ +import { WINDOW } from '@sentry/browser'; +import { 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); + }, + }; +} From e61b62e247e3e05eded63c9652fe79db2acdafcc Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Fri, 10 Nov 2023 16:57:20 -0500 Subject: [PATCH 2/3] wip --- packages/feedback/src/sendFeedback.ts | 9 +- packages/feedback/src/types/index.ts | 23 ++ .../feedback/src/util/handleFeedbackSubmit.ts | 5 +- .../feedback/src/util/imageEditor/index.ts | 53 +++- .../feedback/src/util/imageEditor/tool.ts | 25 +- .../feedback/src/util/imageEditor/utils.ts | 71 +++++- .../feedback/src/util/sendFeedbackRequest.ts | 15 +- packages/feedback/src/widget/Dialog.ts | 21 +- packages/feedback/src/widget/Form.ts | 2 +- packages/feedback/src/widget/createWidget.ts | 7 +- .../src/widget/screenshot/ImageEditor.ts | 179 -------------- .../src/widget/screenshot/Screenshot.css.ts | 109 ++++++-- .../widget/screenshot/ScreenshotAnnotator.ts | 234 ++++++++++++++++++ .../screenshot/createScreenshotWidget.ts | 171 ++++++++----- .../feedback/src/widget/screenshot/form.ts | 74 +++--- .../feedback/src/widget/screenshot/icons.ts | 56 +++-- .../src/widget/screenshot/screenshotEditor.ts | 84 +++++-- .../widget/screenshot/screenshotEditorHelp.ts | 3 +- 18 files changed, 757 insertions(+), 384 deletions(-) delete mode 100644 packages/feedback/src/widget/screenshot/ImageEditor.ts create mode 100644 packages/feedback/src/widget/screenshot/ScreenshotAnnotator.ts 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 index 02a13592dc6e..755aa6c7a87c 100644 --- a/packages/feedback/src/util/imageEditor/index.ts +++ b/packages/feedback/src/util/imageEditor/index.ts @@ -1,5 +1,6 @@ import { WINDOW } from '@sentry/browser'; -import { IDrawing, ITool, Rect } from './types'; + +import type { IDrawing, ITool, Rect } from './types'; import { Point, translateBoundingBoxToDocument, translateMouseEvent, translatePointToCanvas } from './utils'; interface Options { @@ -164,9 +165,12 @@ const getScaling = (width: number, height: number) => { return Math.max(Math.sqrt(area / SCALEING_BASE), 1); }; +/** + * + */ export class ImageEditor { private canvas: HTMLCanvasElement; - private ctx: CanvasRenderingContext2D; + private ctx: CanvasRenderingContext2D | null; private drawings: IDrawing[] = []; private scheduledFrame: number | null = null; private image: HTMLImageElement; @@ -198,7 +202,7 @@ export class ImageEditor { this.canvas.height = image.height; this.drawingScaling = getScaling(image.width, image.height); this.sheduleUpdateCanvas(); - onLoad(); + onLoad && onLoad(); }); } @@ -209,6 +213,9 @@ export class ImageEditor { window.addEventListener('keydown', this.handleDelete); } + /** + * + */ destroy() { this.canvas.removeEventListener('click', this.handleClick); this.canvas.removeEventListener('mousedown', this.handleMouseDown); @@ -219,6 +226,9 @@ export class ImageEditor { this.drawings = []; } + /** + * + */ set tool(tool: ITool | null) { if (this._tool?.isDrawing) { // end the current drawing and discard it @@ -229,10 +239,16 @@ export class ImageEditor { 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) { @@ -242,10 +258,16 @@ export class ImageEditor { } } + /** + * + */ get color(): string { return this._color; } + /** + * + */ set strokeSize(strokeSize: number) { this._strokeSize = strokeSize; if (this.selectedDrawingId) { @@ -254,19 +276,26 @@ export class ImageEditor { } } + /** + * + */ 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 => { - drawing.drawToCanvas(this.ctx, drawing.id === this.selectedDrawingId); + this.ctx && drawing.drawToCanvas(this.ctx, drawing.id === this.selectedDrawingId); }); - if (this._tool?.isDrawing) { + if (this._tool && this._tool.isDrawing) { const drawing = this._tool.getDrawingBuffer(); - if (drawing) { + if (drawing && this.ctx) { drawing.drawToCanvas(this.ctx, false); } } @@ -284,8 +313,8 @@ export class ImageEditor { return; } const point = translateMouseEvent(e, this.canvas); - const drawing = [...this.drawings].reverse().find(d => d.isInPath(this.ctx, point)); - this.selectedDrawingId = drawing?.id; + 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; @@ -343,7 +372,7 @@ export class ImageEditor { return; } const delta = Point.fromNumber(e.movementX, e.movementY); - selectedDrawing.moveBy(translatePointToCanvas(delta, this.canvas)); + selectedDrawing && selectedDrawing.moveBy(translatePointToCanvas(delta, this.canvas)); this.resizer.move(e.movementX, e.movementY); this.sheduleUpdateCanvas(); }; @@ -354,7 +383,7 @@ export class ImageEditor { return; } const delta = Point.fromNumber(e.movementX, e.movementY); - selectedDrawing.scaleBy(translatePointToCanvas(delta, this.canvas)); + selectedDrawing && selectedDrawing.scaleBy(translatePointToCanvas(delta, this.canvas)); this.resizer.resize(e.movementX, e.movementY); this.sheduleUpdateCanvas(); }; @@ -363,8 +392,8 @@ export class ImageEditor { return this.canvas.toDataURL(); }; - public getBlob = (): Promise => { - return new Promise(resolve => { + 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 index 6577beaf93f5..e21849d9736a 100644 --- a/packages/feedback/src/util/imageEditor/tool.ts +++ b/packages/feedback/src/util/imageEditor/tool.ts @@ -1,4 +1,4 @@ -import { IDrawing, IPoint, ITool, Rect } from './types'; +import type { IDrawing, IPoint, ITool, Rect } from './types'; import { getPointsBoundingBox, Point, translateRect, updateBoundingBox, Vector } from './utils'; class Tool implements ITool { @@ -24,13 +24,13 @@ class Tool implements ITool { if (!this.isDrawing) { throw new Error('Call startDrawing before calling draw'); } - this.drawing.draw(point); + this.drawing && this.drawing.draw(point); } endDrawing(point: IPoint) { if (!this.isDrawing) { throw new Error('Call startDrawing before calling endDrawing'); } - this.drawing.end(point); + this.drawing && this.drawing.end(point); const drawing = this.drawing; this.drawing = null; return drawing; @@ -44,7 +44,7 @@ class Drawing implements IDrawing { protected path = new Path2D(); protected startPoint: IPoint; protected endPoint: IPoint; - protected translate: IPoint = { x: 0, y: 0 }; + protected translate: IPoint; protected color = 'red'; protected strokeSize = 6; protected strokeScalingFactor = 1; @@ -60,6 +60,9 @@ class Drawing implements IDrawing { 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() { @@ -193,6 +196,9 @@ class RectangleDrawing extends Drawing { }; } +/** + * + */ export class Rectangle extends Tool { constructor() { super(RectangleDrawing); @@ -203,6 +209,11 @@ 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 { @@ -244,6 +255,9 @@ class PenDrawing extends Drawing { }; } +/** + * + */ export class Pen extends Tool { constructor() { super(PenDrawing); @@ -272,6 +286,9 @@ class ArrowDrawing extends Drawing { }; } +/** + * + */ export class Arrow extends Tool { constructor() { super(ArrowDrawing); diff --git a/packages/feedback/src/util/imageEditor/utils.ts b/packages/feedback/src/util/imageEditor/utils.ts index dac903d9476d..d61f14655613 100644 --- a/packages/feedback/src/util/imageEditor/utils.ts +++ b/packages/feedback/src/util/imageEditor/utils.ts @@ -1,17 +1,26 @@ -import { IPoint, Rect } from './types'; +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)); } @@ -21,6 +30,9 @@ export class Vector implements IPoint { this.y = point.y; } + /** + * + */ normalize() { const length = this.length; return new Vector({ @@ -29,6 +41,9 @@ export class Vector implements IPoint { }); } + /** + * + */ rotate(angle: number) { const cos = Math.cos(angle); const sin = Math.sin(angle); @@ -39,7 +54,13 @@ export class Vector implements IPoint { } } +/** + * + */ export class Point { + /** + * + */ static fromMouseEvent(e: MouseEvent): IPoint { return { x: e.clientX, @@ -47,10 +68,16 @@ export class Point { }; } + /** + * + */ 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 { @@ -59,6 +86,9 @@ export class Point { }; } + /** + * + */ static divide(point: IPoint, divisor: number | IPoint): IPoint { const div = asPoint(divisor); return { @@ -67,6 +97,9 @@ export class Point { }; } + /** + * + */ static add(point1: IPoint, point2: number | IPoint): IPoint { const point = asPoint(point2); return { @@ -75,6 +108,9 @@ export class Point { }; } + /** + * + */ static subtract(point1: IPoint, point2: number | IPoint): IPoint { const point = asPoint(point2); return { @@ -83,10 +119,16 @@ export class Point { }; } + /** + * + */ 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), @@ -95,6 +137,9 @@ export class Point { } } +/** + * + */ export function getCanvasScaleRatio(canvas: HTMLCanvasElement): IPoint { const rect = canvas.getBoundingClientRect(); const verticalScale = canvas.height / rect.height; @@ -105,14 +150,23 @@ export function getCanvasScaleRatio(canvas: HTMLCanvasElement): IPoint { }; } +/** + * + */ 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); @@ -124,6 +178,9 @@ export function translateBoundingBoxToDocument(boundingBox: Rect, canvas: HTMLCa }; } +/** + * + */ export function translateMouseEvent(e: MouseEvent, canvas: HTMLCanvasElement): IPoint { const ratio = getCanvasScaleRatio(canvas); const clientRect = canvas.getBoundingClientRect(); @@ -131,10 +188,16 @@ export function translateMouseEvent(e: MouseEvent, canvas: HTMLCanvasElement): I 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, @@ -144,6 +207,9 @@ export function translateRect(rect: Rect, vector: IPoint): Rect { }; } +/** + * + */ export function getPointsBoundingBox(points: IPoint[]): Rect { const xValues = points.map(p => p.x); const yValues = points.map(p => p.y); @@ -159,6 +225,9 @@ export function getPointsBoundingBox(points: IPoint[]): Rect { }; } +/** + * + */ export function updateBoundingBox(boundingBox: Rect, points: IPoint[]): Rect { return getPointsBoundingBox([ ...points, 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/widget/Dialog.ts b/packages/feedback/src/widget/Dialog.ts index ca559d33dee7..e5e4c85a4a26 100644 --- a/packages/feedback/src/widget/Dialog.ts +++ b/packages/feedback/src/widget/Dialog.ts @@ -1,4 +1,10 @@ -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'; @@ -6,9 +12,10 @@ 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 { @@ -95,6 +102,11 @@ 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 { @@ -109,7 +121,7 @@ export function Dialog({ defaultName, defaultEmail, - onSubmit, + onSubmit: handleSubmit, onCancel, ...textLabels, }); @@ -149,7 +161,8 @@ export function Dialog({ ), formEl, ), - screenshot.ScreenshotDialog.el, + screenshot.dialogEl, + screenshot.ScreenshotStyles, ); return { diff --git a/packages/feedback/src/widget/Form.ts b/packages/feedback/src/widget/Form.ts index 33a202a27fe7..801910c53932 100644 --- a/packages/feedback/src/widget/Form.ts +++ b/packages/feedback/src/widget/Form.ts @@ -1,5 +1,5 @@ import type { FeedbackComponent, FeedbackFormData, FeedbackInternalOptions, FeedbackTextConfiguration } from '../types'; -import { ScreenshotFormComponent } from './screenshot/form'; +import type { ScreenshotFormComponent } from './screenshot/form'; import { SubmitButton } from './SubmitButton'; import { createElement } from './util/createElement'; 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/ImageEditor.ts b/packages/feedback/src/widget/screenshot/ImageEditor.ts deleted file mode 100644 index 4f0888ccdc80..000000000000 --- a/packages/feedback/src/widget/screenshot/ImageEditor.ts +++ /dev/null @@ -1,179 +0,0 @@ -// import React, {ComponentType, useCallback, useEffect, useMemo, useState} from 'react'; -// import styled from '@emotion/styled'; -// -import { WINDOW } from '@sentry/browser'; -import { createElement } from '../util/createElement'; -// import { ToolKey, Tools, useImageEditor } from './hooks/useImageEditor'; -import { ArrowIcon, HandIcon, IconComponent, 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 ImageEditorWrapperProps { - onCancel: () => void; - onSubmit: (screenshot: Blob) => void; - src: string; -} - -// const ColorDisplay = styled.div<{ color: string }>` -// ${({ color }) => `background-color: ${color};`} -// `; -// -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(); -} - -export function ImageEditorWrapper({ src, onCancel, onSubmit }: ImageEditorWrapperProps) { - let selectedColor = ''; - - 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`; - }; - - const image = () => srcToImage(src); - // const { selectedTool, setSelectedTool, selectedColor, setSelectedColor, getBlob } = useImageEditor({ - // canvas, - // image, - // onLoad: resizeCanvas, - // }); - - resizeCanvas(); - window.addEventListener('resize', resizeCanvas); - - function handleSubmit(): void {} - function setSelectedColor(color: string) {} - function setSelectedTool(tool: string) {} - - const canvas = createElement('canvas', { className: '.image-editor__canvas' }); - const wrapper = createElement('div', { className: 'image-editor__canvas__wrapper' }, canvas); - const toolbarGroupTools = createElement( - 'div', - { className: 'image-editor__toolbar__group' }, - Tools.map(tool => - createElement( - 'button', - { className: 'image-editor__tool-button', onClick: () => setSelectedTool(tool) }, - ToolIcon({ tool }).el, - ), - ), - ); - const colorDisplay = createElement('div', { - className: 'image-editor__color-display', - style: `background-color: ${selectedColor}`, - }); - - const colorInput = createElement('input', { - type: 'color', - value: selectedColor, - onChange: (e: Event): void => { - e.target && setSelectedColor((e.target as HTMLInputElement).value); - }, - }); - - const colorInputLabel = createElement( - 'label', - { - className: 'iamge-editor__color-input', - }, - colorDisplay, - colorInput, - ); - const toolbarGroupColor = createElement('div', { className: 'image-editor__toolbar__group' }, colorInputLabel); - const toolbar = createElement( - 'div', - { className: 'image-editor__canvas__toolbar' }, - createElement('button', { className: 'image-editor__canvas__cancel', onClick: onCancel }, 'Cancel'), - createElement('div', { className: 'image-editor__spacer' }), - toolbarGroupTools, - toolbarGroupColor, - createElement('div', { className: 'image-editor__spacer' }), - createElement('button', { className: 'image-editor__canvas__submit', onClick: handleSubmit }, 'Save'), - ); - - const imageEditor = createElement('div', { className: 'image-editor__container' }, wrapper, toolbar); - - return { - get el() { - return imageEditor; - }, - remove() { - WINDOW.removeEventListener('resize', resizeCanvas); - }, - }; - - // return ( - // - // - // - // - // - // onCancel()}>Cancel - // - // - // {Tools.map(tool => ( - // setSelectedTool(tool)}> - // - // - // ))} - // - // - // - // - // setSelectedColor(e.target.value)} /> - // - // - // - // onSubmit(await getBlob())}>Save - // - // - // ); -} diff --git a/packages/feedback/src/widget/screenshot/Screenshot.css.ts b/packages/feedback/src/widget/screenshot/Screenshot.css.ts index 55ca7fdd0a77..93c4092f982a 100644 --- a/packages/feedback/src/widget/screenshot/Screenshot.css.ts +++ b/packages/feedback/src/widget/screenshot/Screenshot.css.ts @@ -1,7 +1,16 @@ +/** + * + */ 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; @@ -26,6 +35,10 @@ export function createScreenshotStyles(d: Document): HTMLStyleElement { ); } +.screenshot-editor__container[aria-hidden="true"] { + display: none; +} + .screenshot-editor__help { position: fixed; width: 100vw; @@ -53,7 +66,64 @@ export function createScreenshotStyles(d: Document): HTMLStyleElement { box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), 0 4px 16px rgba(0, 0, 0, 0.2); } -.image-editor__container { +.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; @@ -70,13 +140,17 @@ export function createScreenshotStyles(d: Document): HTMLStyleElement { ); } -.image-editor__canvas { +.screenshot-annotator__container[aria-hidden="true"] { + display: none; +} + +.screenshot-annotator__canvas { cursor: crosshair; max-width: 100vw; max-height: 100vh; } -.image-editor__canvas__wrapper { +.screenshot-annotator__canvas__wrapper { position: relative; width: 100%; margin-top: 32px; @@ -86,21 +160,22 @@ export function createScreenshotStyles(d: Document): HTMLStyleElement { justify-content: center; } -.image-editor__toolbar { +.screenshot-annotator__toolbar { position: absolute; - width: 100%; +left: 16px; +right: 16px; bottom: 0px; - padding: 12px 16px; + padding: 12px 0; display: flex; gap: 12px; flex-direction: row; - justify-content: center; + justify-content: space-between; + align-items: center; } -.image-editor__toolbar__group { +.screenshot-annotator__toolbar__group { display: flex; flex-direction: row; - height: 42px; background-color: white; border: rgba(58, 17, 95, 0.14) 1px solid; border-radius: 10px; @@ -110,7 +185,7 @@ export function createScreenshotStyles(d: Document): HTMLStyleElement { box-shadow: 0px 1px 2px 1px rgba(43, 34, 51, 0.04); } -.image-editor__tool-button { +.screenshot-annotator__tool-button { width: 32px; height: 32px; border-radius: 6px; @@ -128,20 +203,20 @@ export function createScreenshotStyles(d: Document): HTMLStyleElement { } } -.image-editor__tool-button--active { +.screenshot-annotator__tool-button--active { background-color: rgba(108, 95, 199, 1) !important; color: white; } -.image-editor__tool-icon { +.screenshot-annotator__tool-icon { } -.image-editor__spacer { +.screenshot-annotator__spacer { flex: 1; } -.image-editor__cancel { +.screenshot-annotator__cancel { height: 40px; width: 84px; border: rgba(58, 17, 95, 0.14) 1px solid; @@ -157,7 +232,7 @@ export function createScreenshotStyles(d: Document): HTMLStyleElement { } -.image-editor__submit { +.screenshot-annotator__submit { height: 40px; width: 84px; border: none; @@ -173,13 +248,13 @@ export function createScreenshotStyles(d: Document): HTMLStyleElement { } -.image-editor__color-display { +.screenshot-annotator__color-display { width: 16px; height: 16px; border-radius: 4px; } -.image-editor__color-input { +.screenshot-annotator__color-input { position: relative; display: flex; width: 32px; 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 index 6f3d7ef66d88..2ef45d158a91 100644 --- a/packages/feedback/src/widget/screenshot/createScreenshotWidget.ts +++ b/packages/feedback/src/widget/screenshot/createScreenshotWidget.ts @@ -1,8 +1,17 @@ import { WINDOW } from '@sentry/browser'; -import { FeedbackComponent } from '../../types'; + +import type { Screenshot } from '../../types'; import { createElement } from '../util/createElement'; -import { ScreenshotForm } from './form'; -import { Rect, ScreenshotEditor } from './screenshotEditor'; +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, _) => { @@ -16,9 +25,16 @@ function blobToBase64(blob: Blob | null): Promise { }); } +/** + * + */ export function createScreenshotWidget(): { + dialogEl: HTMLDivElement; + formEl: HTMLDivElement | null; ScreenshotForm: ReturnType; - ScreenshotDialog: ReturnType; + ScreenshotStyles: ReturnType; + // processScreenshot: (scope: Scope) => void; + getData: () => Promise; } { let selection: Rect | undefined; let screenshot: Blob | null = null; @@ -26,117 +42,142 @@ export function createScreenshotWidget(): { 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) {} + /** + * + */ + 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, - cutout?: Blob | null, + newCutout?: Blob | null, newSelection?: Rect, ): Promise { setScreenshot(newScreenshot); - setScreenshotCutout(cutout || null); + setScreenshotCutout(newCutout || null); setScreenshotPreview(await blobToBase64(newScreenshot)); - setScreenshotCutoutPreview((cutout && (await blobToBase64(cutout))) || ''); + setScreenshotCutoutPreview((newCutout && (await blobToBase64(newCutout))) || ''); + screenshotForm.setFormPreview(await blobToBase64(newScreenshot)); + screenshotForm.setFormCutoutPreview((newCutout && (await blobToBase64(newCutout))) || ''); selection = newSelection; + + screenshotEditor.hide(); } - const screenshotDialog = ScreenshotDialog({ - screenshot, - screenshotPreview, - screenshotCutout, - screenshotCutoutPreview, - onSubmit: handleEditorSubmit, - }); + const screenshotEditor = ScreenshotEditor({ onSubmit: handleEditorSubmit }); const screenshotForm = ScreenshotForm({ - screenshotPreview, - screenshotCutout, - screenshotCutoutPreview, onEditCutout: () => setEdit('cutout', true), onEditScreenshot: () => setEdit('screenshot', true), onTakeScreenshot: (image: string) => { setScreenshotPreview(image); + screenshotEditor.show(image); }, }); - return { - ScreenshotForm: screenshotForm, - ScreenshotDialog: screenshotDialog, - }; -} - -interface ScreenshotDialogProps { - screenshot: Blob | null; - /** - * base64 of screenshot preview - */ - screenshotPreview: string; - screenshotCutout: Blob | null; - /** - * base64 of `screenshotCutout` - */ - screenshotCutoutPreview: string; - onSubmit: (screenshot: Blob | null, cutout?: Blob | null, selection?: Rect) => void; -} - -function ScreenshotDialog({ - screenshot, - screenshotPreview, - screenshotCutoutPreview, - onSubmit, -}: ScreenshotDialogProps): FeedbackComponent { - const fragment = WINDOW.document.createDocumentFragment(); - - const screenshotEditor = ScreenshotEditor({ dataUrl: screenshotPreview, onSubmit }); - - const screenshotEditorWrapperEl = createElement('div', { - className: 'screenshot-image-editor__wrapper', - src: screenshotPreview, + const screenshotAnnotator = ScreenshotAnnotator({ onSubmit: async newScreenshot => { - // setScreenshot(newScreenshot); - // setScreenshotPreview(await blobToBase64(newScreenshot)); - // setIsEditScreenshotOpen(false); + setScreenshot(newScreenshot); + setScreenshotPreview(await blobToBase64(newScreenshot)); + screenshotForm.setFormPreview(await blobToBase64(newScreenshot)); + setEdit('screenshot', false); }, onCancel: () => { + setEdit('screenshot', false); // setIsEditScreenshotOpen(false); }, }); - const cutoutEditorEl = createElement('div', { - className: 'screenshot-image-editor__wrapper', - src: screenshotCutoutPreview, + const cutoutAnnotator = ScreenshotAnnotator({ onSubmit: async newCutout => { - // setScreenshotCutout(newCutout); - // setScreenshotCutoutPreview(await blobToBase64(newCutout)); - // setIsEditCutoutOpen(false); + setScreenshotCutout(newCutout); + setScreenshotCutoutPreview(await blobToBase64(newCutout)); + screenshotForm.setFormCutoutPreview((newCutout && (await blobToBase64(newCutout))) || ''); + setEdit('cutout', false); }, onCancel: () => { - // setIsEditCutoutOpen(false); + setEdit('cutout', false); }, }); - const el = createElement('div', {}, [ + const dialogEl = createElement('div', {}, [ screenshotPreview && !screenshot && screenshotEditor.el, screenshotEditor.el, - cutoutEditorEl, + screenshotAnnotator.el, + cutoutAnnotator.el, ]); return { - get el() { - return el; + 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 index f013cdf4e05b..b3fe50b848a6 100644 --- a/packages/feedback/src/widget/screenshot/form.ts +++ b/packages/feedback/src/widget/screenshot/form.ts @@ -1,19 +1,10 @@ import { logger } from '@sentry/utils'; -import { FeedbackComponent } from '../../types'; + +import type { FeedbackComponent } from '../../types'; import { takeScreenshot } from '../../util/takeScreenshot'; import { createElement } from '../util/createElement'; interface ScreenshotFormProps { - /** - * base64 of screenshot preview - */ - screenshotPreview: string; - screenshotCutout: Blob | null; - /** - * base64 of `screenshotCutout` - */ - screenshotCutoutPreview: string; - onEditScreenshot: () => void; onEditCutout: () => void; onTakeScreenshot: (image: string) => void; @@ -28,9 +19,6 @@ export interface ScreenshotFormComponent extends FeedbackComponentScreenshot - // {screenshotPreview ? ( - // - // setIsEditScreenshotOpen(true)} - // > - // - // - // {screenshotCutout && ( - // setIsEditCutoutOpen(true)} - // > - // - // - // )} - // - // ) : ( - // - // Add Screenshot - // - // )} + return { get el() { return el; diff --git a/packages/feedback/src/widget/screenshot/icons.ts b/packages/feedback/src/widget/screenshot/icons.ts index 7da169c03566..5e54543b0e44 100644 --- a/packages/feedback/src/widget/screenshot/icons.ts +++ b/packages/feedback/src/widget/screenshot/icons.ts @@ -15,11 +15,11 @@ const createElementNS = ( Object.entries(attributes).forEach(([attribute, attributeValue]) => { if (attribute === 'className' && typeof attributeValue === 'string') { // JSX does not allow class as a valid name - el.setAttributeNS(XMLNS, 'class', attributeValue); + el.setAttributeNS(null, 'class', attributeValue); } else if (typeof attributeValue === 'boolean' && attributeValue) { - el.setAttributeNS(XMLNS, attribute, ''); + el.setAttributeNS(null, attribute, ''); } else if (typeof attributeValue === 'string') { - el.setAttributeNS(XMLNS, attribute, attributeValue); + el.setAttributeNS(null, attribute, attributeValue); } else if (attribute.startsWith('on') && typeof attributeValue === 'function') { el.addEventListener(attribute.substring(2).toLowerCase(), attributeValue); } @@ -53,6 +53,9 @@ export interface IconComponent { el: SVGSVGElement; } +/** + * + */ export function PenIcon(): IconComponent { return { get el() { @@ -67,29 +70,32 @@ export function PenIcon(): IconComponent { createElementNS('path', { d: 'M8.5 12L12 8.5L14 11L11 14L8.5 12Z', stroke: 'currentColor', - strokeWidth: '1.5', - strokeLinecap: 'round', - strokeLinejoin: 'round', + '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', - strokeWidth: '1.5', - strokeLinecap: 'round', - strokeLinejoin: 'round', + 'stroke-width': '1.5', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', }), createElementNS('path', { d: 'M2 2L7.5 7.5', stroke: 'currentColor', - strokeWidth: '1.5', - strokeLinecap: 'round', - strokeLinejoin: 'round', + 'stroke-width': '1.5', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', }), ); }, }; } +/** + * + */ export function RectangleIcon(): IconComponent { return { get el() { @@ -108,13 +114,16 @@ export function RectangleIcon(): IconComponent { height: '11', rx: '2', stroke: 'currentColor', - strokeWidth: '1.5', + 'stroke-width': '1.5', }), ); }, }; } +/** + * + */ export function ArrowIcon(): IconComponent { return { get el() { @@ -129,22 +138,25 @@ export function ArrowIcon(): IconComponent { createElementNS('path', { d: 'M2.5 2.5L13 13', stroke: 'currentColor', - strokeWidth: '1.5', - strokeLinecap: 'round', - strokeLinejoin: 'round', + 'stroke-width': '1.5', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', }), createElementNS('path', { d: 'M8.5 2.5H2.5L2.5 8.5', stroke: 'currentColor', - strokeWidth: '1.5', - strokeLinecap: 'round', - strokeLinejoin: 'round', + 'stroke-width': '1.5', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', }), ); }, }; } +/** + * + */ export function HandIcon(): IconComponent { return { get el() { @@ -159,9 +171,9 @@ export function HandIcon(): IconComponent { createElementNS('path', { d: 'M2 2L6.5 14.5L8.5 8.5L14.5 6.5L2 2Z', stroke: 'currentColor', - strokeWidth: '1.5', - strokeLinecap: 'round', - strokeLinejoin: 'round', + '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 index 92347fb89b3b..8ef1b75ed533 100644 --- a/packages/feedback/src/widget/screenshot/screenshotEditor.ts +++ b/packages/feedback/src/widget/screenshot/screenshotEditor.ts @@ -1,4 +1,6 @@ import { WINDOW } from '@sentry/browser'; + +import type { FeedbackComponent } from '../../types'; import { createElement } from '../util/createElement'; import { ScreenshotEditorHelp } from './screenshotEditorHelp'; @@ -8,11 +10,22 @@ export interface Rect { x: number; y: number; } + +interface Point { + x: number; + y: number; +} + interface ScreenshotEditorProps { - dataUrl: string; 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; @@ -37,10 +50,6 @@ const canvasToBlob = (canvas: HTMLCanvasElement): Promise => { }); }); }; -interface Point { - x: number; - y: number; -} const constructRect = (start: Point, end: Point): Rect => { return { @@ -51,11 +60,19 @@ const constructRect = (start: Point, end: Point): Rect => { }; }; -export function ScreenshotEditor({ dataUrl, onSubmit }: ScreenshotEditorProps) { +/** + * + */ +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' }, canvas, screenshotEditorHelp.el); + 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(); @@ -63,6 +80,9 @@ export function ScreenshotEditor({ dataUrl, onSubmit }: ScreenshotEditorProps) { 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`; @@ -73,6 +93,9 @@ export function ScreenshotEditor({ dataUrl, onSubmit }: ScreenshotEditorProps) { currentRatio = renderSize.width / img.width; } + /** + * + */ function refreshCanvas(): void { if (!ctx) { return; @@ -100,6 +123,9 @@ export function ScreenshotEditor({ dataUrl, onSubmit }: ScreenshotEditorProps) { ctx.strokeRect(rect.x, rect.y, rect.width, rect.height); } + /** + * + */ async function submit(rect?: Rect): Promise { const imageBlob = await canvasToBlob(canvas); if (!rect) { @@ -115,17 +141,26 @@ export function ScreenshotEditor({ dataUrl, onSubmit }: ScreenshotEditorProps) { 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); @@ -137,6 +172,9 @@ export function ScreenshotEditor({ dataUrl, onSubmit }: ScreenshotEditorProps) { void submit(constructRect(rectStart, rectEnd)); } + /** + * + */ function handleEnterKey(e: KeyboardEvent): void { if (e.key === 'Enter') { void submit(); @@ -150,14 +188,6 @@ export function ScreenshotEditor({ dataUrl, onSubmit }: ScreenshotEditorProps) { ctx && ctx.drawImage(img, 0, 0); }; - 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); - return { get el() { return el; @@ -170,11 +200,23 @@ export function ScreenshotEditor({ dataUrl, onSubmit }: ScreenshotEditorProps) { 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 index 029abb695ee4..86518e411133 100644 --- a/packages/feedback/src/widget/screenshot/screenshotEditorHelp.ts +++ b/packages/feedback/src/widget/screenshot/screenshotEditorHelp.ts @@ -1,5 +1,6 @@ import { WINDOW } from '@sentry/browser'; -import { FeedbackComponent } from '../../types'; + +import type { FeedbackComponent } from '../../types'; import { createElement } from '../util/createElement'; interface ScreenshotEditorHelpComponent extends FeedbackComponent { From 83d7896cafc30c226e702d611e8e5e48415f8cb2 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 14 Nov 2023 10:51:03 -0500 Subject: [PATCH 3/3] remove selection --- .../src/widget/screenshot/createScreenshotWidget.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/feedback/src/widget/screenshot/createScreenshotWidget.ts b/packages/feedback/src/widget/screenshot/createScreenshotWidget.ts index 2ef45d158a91..3e0c61ada093 100644 --- a/packages/feedback/src/widget/screenshot/createScreenshotWidget.ts +++ b/packages/feedback/src/widget/screenshot/createScreenshotWidget.ts @@ -5,7 +5,7 @@ import { createElement } from '../util/createElement'; import { ScreenshotForm } from './Form'; import { createScreenshotStyles } from './Screenshot.css'; import { ScreenshotAnnotator } from './ScreenshotAnnotator'; -import type { Rect } from './ScreenshotEditor'; +// import type { Rect } from './ScreenshotEditor'; import { ScreenshotEditor } from './ScreenshotEditor'; async function blobToUint8Array(blob: Blob): Promise { @@ -33,10 +33,9 @@ export function createScreenshotWidget(): { formEl: HTMLDivElement | null; ScreenshotForm: ReturnType; ScreenshotStyles: ReturnType; - // processScreenshot: (scope: Scope) => void; getData: () => Promise; } { - let selection: Rect | undefined; + // let selection: Rect | undefined; let screenshot: Blob | null = null; let screenshotCutout: Blob | null = null; let screenshotPreview: string = ''; @@ -86,7 +85,7 @@ export function createScreenshotWidget(): { async function handleEditorSubmit( newScreenshot: Blob | null, newCutout?: Blob | null, - newSelection?: Rect, + // newSelection?: Rect, ): Promise { setScreenshot(newScreenshot); setScreenshotCutout(newCutout || null); @@ -94,7 +93,7 @@ export function createScreenshotWidget(): { setScreenshotCutoutPreview((newCutout && (await blobToBase64(newCutout))) || ''); screenshotForm.setFormPreview(await blobToBase64(newScreenshot)); screenshotForm.setFormCutoutPreview((newCutout && (await blobToBase64(newCutout))) || ''); - selection = newSelection; + // selection = newSelection; screenshotEditor.hide(); }