diff --git a/packages/core/src/types-hoist/feedback/config.ts b/packages/core/src/types-hoist/feedback/config.ts index 4ec846c7d98d..d7b3d78995bb 100644 --- a/packages/core/src/types-hoist/feedback/config.ts +++ b/packages/core/src/types-hoist/feedback/config.ts @@ -57,6 +57,15 @@ export interface FeedbackGeneralConfiguration { name: string; }; + /** + * _experiments allows users to enable experimental or internal features. + * We don't consider such features as part of the public API and hence we don't guarantee semver for them. + * Experimental features can be added, changed or removed at any time. + * + * Default: undefined + */ + _experiments: Partial<{ annotations: boolean }>; + /** * Set an object that will be merged sent as tags data with the event. */ diff --git a/packages/feedback/src/core/integration.ts b/packages/feedback/src/core/integration.ts index e5f1092856f1..8b312b902258 100644 --- a/packages/feedback/src/core/integration.ts +++ b/packages/feedback/src/core/integration.ts @@ -84,6 +84,7 @@ export const buildFeedbackIntegration = ({ email: 'email', name: 'username', }, + _experiments = {}, tags, styleNonce, scriptNonce, @@ -158,6 +159,8 @@ export const buildFeedbackIntegration = ({ onSubmitError, onSubmitSuccess, onFormSubmitted, + + _experiments, }; let _shadow: ShadowRoot | null = null; diff --git a/packages/feedback/src/screenshot/components/PenIcon.tsx b/packages/feedback/src/screenshot/components/PenIcon.tsx new file mode 100644 index 000000000000..ec50862c1dd4 --- /dev/null +++ b/packages/feedback/src/screenshot/components/PenIcon.tsx @@ -0,0 +1,31 @@ +import type { VNode, h as hType } from 'preact'; + +interface FactoryParams { + h: typeof hType; +} + +export default function PenIconFactory({ + h, // eslint-disable-line @typescript-eslint/no-unused-vars +}: FactoryParams) { + return function PenIcon(): VNode { + return ( + + + + + + ); + }; +} diff --git a/packages/feedback/src/screenshot/components/ScreenshotEditor.tsx b/packages/feedback/src/screenshot/components/ScreenshotEditor.tsx index ef33e1b611b0..e242415c0903 100644 --- a/packages/feedback/src/screenshot/components/ScreenshotEditor.tsx +++ b/packages/feedback/src/screenshot/components/ScreenshotEditor.tsx @@ -6,6 +6,7 @@ import { h } from 'preact'; // eslint-disable-line @typescript-eslint/no-unused- import type * as Hooks from 'preact/hooks'; import { DOCUMENT, WINDOW } from '../../constants'; import CropCornerFactory from './CropCorner'; +import PenIconFactory from './PenIcon'; import { createScreenshotInputStyles } from './ScreenshotInput.css'; import { useTakeScreenshotFactory } from './useTakeScreenshot'; @@ -72,40 +73,55 @@ export function ScreenshotEditorFactory({ options, }: FactoryParams): ComponentType { const useTakeScreenshot = useTakeScreenshotFactory({ hooks }); + const CropCorner = CropCornerFactory({ h }); + const PenIcon = PenIconFactory({ h }); return function ScreenshotEditor({ onError }: Props): VNode { const styles = hooks.useMemo(() => ({ __html: createScreenshotInputStyles(options.styleNonce).innerText }), []); - const CropCorner = CropCornerFactory({ h }); const canvasContainerRef = hooks.useRef(null); const cropContainerRef = hooks.useRef(null); const croppingRef = hooks.useRef(null); + const annotatingRef = hooks.useRef(null); const [croppingRect, setCroppingRect] = hooks.useState({ startX: 0, startY: 0, endX: 0, endY: 0 }); const [confirmCrop, setConfirmCrop] = hooks.useState(false); const [isResizing, setIsResizing] = hooks.useState(false); + const [isAnnotating, setIsAnnotating] = hooks.useState(false); hooks.useEffect(() => { - WINDOW.addEventListener('resize', resizeCropper, false); + WINDOW.addEventListener('resize', resize); + + return () => { + WINDOW.removeEventListener('resize', resize); + }; }, []); - function resizeCropper(): void { - const cropper = croppingRef.current; - const imageDimensions = constructRect(getContainedSize(imageBuffer)); - if (cropper) { - cropper.width = imageDimensions.width * DPI; - cropper.height = imageDimensions.height * DPI; - cropper.style.width = `${imageDimensions.width}px`; - cropper.style.height = `${imageDimensions.height}px`; - const ctx = cropper.getContext('2d'); - if (ctx) { - ctx.scale(DPI, DPI); - } + function resizeCanvas(canvasRef: Hooks.Ref, imageDimensions: Rect): void { + const canvas = canvasRef.current; + if (!canvas) { + return; } - const cropButton = cropContainerRef.current; - if (cropButton) { - cropButton.style.width = `${imageDimensions.width}px`; - cropButton.style.height = `${imageDimensions.height}px`; + canvas.width = imageDimensions.width * DPI; + canvas.height = imageDimensions.height * DPI; + canvas.style.width = `${imageDimensions.width}px`; + canvas.style.height = `${imageDimensions.height}px`; + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.scale(DPI, DPI); + } + } + + function resize(): void { + const imageDimensions = constructRect(getContainedSize(imageBuffer)); + + resizeCanvas(croppingRef, imageDimensions); + resizeCanvas(annotatingRef, imageDimensions); + + const cropContainer = cropContainerRef.current; + if (cropContainer) { + cropContainer.style.width = `${imageDimensions.width}px`; + cropContainer.style.height = `${imageDimensions.height}px`; } setCroppingRect({ startX: 0, startY: 0, endX: imageDimensions.width, endY: imageDimensions.height }); @@ -141,6 +157,7 @@ export function ScreenshotEditorFactory({ }, [croppingRect]); function onGrabButton(e: Event, corner: string): void { + setIsAnnotating(false); setConfirmCrop(false); setIsResizing(true); const handleMouseMove = makeHandleMouseMove(corner); @@ -247,7 +264,49 @@ export function ScreenshotEditorFactory({ DOCUMENT.addEventListener('mouseup', handleMouseUp); } - function submit(): void { + function onAnnotateStart(): void { + if (!isAnnotating) { + return; + } + + const handleMouseMove = (moveEvent: MouseEvent): void => { + const annotateCanvas = annotatingRef.current; + if (annotateCanvas) { + const rect = annotateCanvas.getBoundingClientRect(); + + const x = moveEvent.clientX - rect.x; + const y = moveEvent.clientY - rect.y; + + const ctx = annotateCanvas.getContext('2d'); + if (ctx) { + ctx.lineTo(x, y); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(x, y); + } + } + }; + + const handleMouseUp = (): void => { + const ctx = annotatingRef.current?.getContext('2d'); + // starts a new path so on next mouse down, the lines won't connect + if (ctx) { + ctx.beginPath(); + } + + // draws the annotation onto the image buffer + // TODO: move this to a better place + applyAnnotation(); + + DOCUMENT.removeEventListener('mousemove', handleMouseMove); + DOCUMENT.removeEventListener('mouseup', handleMouseUp); + }; + + DOCUMENT.addEventListener('mousemove', handleMouseMove); + DOCUMENT.addEventListener('mouseup', handleMouseUp); + } + + function applyCrop(): void { const cutoutCanvas = DOCUMENT.createElement('canvas'); const imageBox = constructRect(getContainedSize(imageBuffer)); const croppingBox = constructRect(croppingRect); @@ -277,7 +336,32 @@ export function ScreenshotEditorFactory({ imageBuffer.style.width = `${croppingBox.width}px`; imageBuffer.style.height = `${croppingBox.height}px`; ctx.drawImage(cutoutCanvas, 0, 0); - resizeCropper(); + resize(); + } + } + + function applyAnnotation(): void { + // draw the annotations onto the image (ie "squash" the canvases) + const imageCtx = imageBuffer.getContext('2d'); + const annotateCanvas = annotatingRef.current; + if (imageCtx && annotateCanvas) { + imageCtx.drawImage( + annotateCanvas, + 0, + 0, + annotateCanvas.width, + annotateCanvas.height, + 0, + 0, + imageBuffer.width, + imageBuffer.height, + ); + + // clear the annotation canvas + const annotateCtx = annotateCanvas.getContext('2d'); + if (annotateCtx) { + annotateCtx.clearRect(0, 0, annotateCanvas.width, annotateCanvas.height); + } } } @@ -303,7 +387,7 @@ export function ScreenshotEditorFactory({ (dialog.el as HTMLElement).style.display = 'block'; const container = canvasContainerRef.current; container?.appendChild(imageBuffer); - resizeCropper(); + resize(); }, []), onError: hooks.useCallback(error => { (dialog.el as HTMLElement).style.display = 'block'; @@ -314,11 +398,32 @@ export function ScreenshotEditorFactory({ return ( + {options._experiments.annotations && ( + + { + e.preventDefault(); + setIsAnnotating(!isAnnotating); + }} + > + + + + )} - + { e.preventDefault(); - submit(); + applyCrop(); setConfirmCrop(false); }} class="btn btn--primary" @@ -382,6 +487,12 @@ export function ScreenshotEditorFactory({ + ); diff --git a/packages/feedback/src/screenshot/components/ScreenshotInput.css.ts b/packages/feedback/src/screenshot/components/ScreenshotInput.css.ts index ae7eaefba86b..5b439390d068 100644 --- a/packages/feedback/src/screenshot/components/ScreenshotInput.css.ts +++ b/packages/feedback/src/screenshot/components/ScreenshotInput.css.ts @@ -15,6 +15,7 @@ export function createScreenshotInputStyles(styleNonce?: string): HTMLStyleEleme padding-top: 65px; padding-bottom: 65px; flex-grow: 1; + position: relative; background-color: ${surface200}; background-image: repeating-linear-gradient( @@ -44,14 +45,18 @@ export function createScreenshotInputStyles(styleNonce?: string): HTMLStyleEleme .editor__canvas-container canvas { object-fit: contain; - position: relative; + position: absolute; +} + +.editor__crop-container { + position: absolute; } .editor__crop-btn-group { padding: 8px; gap: 8px; border-radius: var(--menu-border-radius, 6px); - background: var(--button-primary-background, var(--background)); + background: var(--button-background, var(--background)); width: 175px; position: absolute; } @@ -84,6 +89,19 @@ export function createScreenshotInputStyles(styleNonce?: string): HTMLStyleEleme border-left: none; border-top: none; } +.editor__tool-container { + position: absolute; + padding: 10px 0px; + top: 0; +} +.editor__pen-tool { + height: 30px; + display: flex; + justify-content: center; + align-items: center; + border: var(--button-border, var(--border)); + border-radius: var(--button-border-radius, 6px); +} `; if (styleNonce) {