Skip to content

Commit 6323549

Browse files
c298leeryan953
andauthored
feat(user feedback): Adds draw tool for UF screenshot annotations (#15062)
- Updates some cropping variable names to make it more clear that it's only meant for cropping - The pen tool button is improved in #15102, which needs to be merged first - The drawing tool button needs to be "on" to annotate, but cropping can happen at any time - Once the button is "on", drawing happens on mouse down and mouse move, and on mouse up, the drawing gets "squashed" onto the image. The "squashing" can be moved to happen at a different time in a future PR if we want to incorporate undo, selection, or erasing - The experimental flag must be on to use annotations: `_experiments: {annotations: true}` https://github.com/user-attachments/assets/2fac5e56-5caf-454b-b8b3-afabbd2c31b9 Closes #15064 --------- Co-authored-by: Ryan Albrecht <ryan.albrecht@sentry.io>
1 parent 3c7450d commit 6323549

File tree

5 files changed

+198
-26
lines changed

5 files changed

+198
-26
lines changed

packages/core/src/types-hoist/feedback/config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,15 @@ export interface FeedbackGeneralConfiguration {
5757
name: string;
5858
};
5959

60+
/**
61+
* _experiments allows users to enable experimental or internal features.
62+
* We don't consider such features as part of the public API and hence we don't guarantee semver for them.
63+
* Experimental features can be added, changed or removed at any time.
64+
*
65+
* Default: undefined
66+
*/
67+
_experiments: Partial<{ annotations: boolean }>;
68+
6069
/**
6170
* Set an object that will be merged sent as tags data with the event.
6271
*/

packages/feedback/src/core/integration.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export const buildFeedbackIntegration = ({
8484
email: 'email',
8585
name: 'username',
8686
},
87+
_experiments = {},
8788
tags,
8889
styleNonce,
8990
scriptNonce,
@@ -158,6 +159,8 @@ export const buildFeedbackIntegration = ({
158159
onSubmitError,
159160
onSubmitSuccess,
160161
onFormSubmitted,
162+
163+
_experiments,
161164
};
162165

163166
let _shadow: ShadowRoot | null = null;
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { VNode, h as hType } from 'preact';
2+
3+
interface FactoryParams {
4+
h: typeof hType;
5+
}
6+
7+
export default function PenIconFactory({
8+
h, // eslint-disable-line @typescript-eslint/no-unused-vars
9+
}: FactoryParams) {
10+
return function PenIcon(): VNode {
11+
return (
12+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
13+
<path
14+
d="M8.5 12L12 8.5L14 11L11 14L8.5 12Z"
15+
stroke="currentColor"
16+
strokeWidth="1.5"
17+
strokeLinecap="round"
18+
strokeLinejoin="round"
19+
/>
20+
<path
21+
d="M12 8.5L11 3.5L2 2L3.5 11L8.5 12L12 8.5Z"
22+
stroke="currentColor"
23+
strokeWidth="1.5"
24+
strokeLinecap="round"
25+
strokeLinejoin="round"
26+
/>
27+
<path d="M2 2L7.5 7.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
28+
</svg>
29+
);
30+
};
31+
}

packages/feedback/src/screenshot/components/ScreenshotEditor.tsx

Lines changed: 135 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { h } from 'preact'; // eslint-disable-line @typescript-eslint/no-unused-
66
import type * as Hooks from 'preact/hooks';
77
import { DOCUMENT, WINDOW } from '../../constants';
88
import CropCornerFactory from './CropCorner';
9+
import PenIconFactory from './PenIcon';
910
import { createScreenshotInputStyles } from './ScreenshotInput.css';
1011
import { useTakeScreenshotFactory } from './useTakeScreenshot';
1112

@@ -72,40 +73,55 @@ export function ScreenshotEditorFactory({
7273
options,
7374
}: FactoryParams): ComponentType<Props> {
7475
const useTakeScreenshot = useTakeScreenshotFactory({ hooks });
76+
const CropCorner = CropCornerFactory({ h });
77+
const PenIcon = PenIconFactory({ h });
7578

7679
return function ScreenshotEditor({ onError }: Props): VNode {
7780
const styles = hooks.useMemo(() => ({ __html: createScreenshotInputStyles(options.styleNonce).innerText }), []);
78-
const CropCorner = CropCornerFactory({ h });
7981

8082
const canvasContainerRef = hooks.useRef<HTMLDivElement>(null);
8183
const cropContainerRef = hooks.useRef<HTMLDivElement>(null);
8284
const croppingRef = hooks.useRef<HTMLCanvasElement>(null);
85+
const annotatingRef = hooks.useRef<HTMLCanvasElement>(null);
8386
const [croppingRect, setCroppingRect] = hooks.useState<Box>({ startX: 0, startY: 0, endX: 0, endY: 0 });
8487
const [confirmCrop, setConfirmCrop] = hooks.useState(false);
8588
const [isResizing, setIsResizing] = hooks.useState(false);
89+
const [isAnnotating, setIsAnnotating] = hooks.useState(false);
8690

8791
hooks.useEffect(() => {
88-
WINDOW.addEventListener('resize', resizeCropper, false);
92+
WINDOW.addEventListener('resize', resize);
93+
94+
return () => {
95+
WINDOW.removeEventListener('resize', resize);
96+
};
8997
}, []);
9098

91-
function resizeCropper(): void {
92-
const cropper = croppingRef.current;
93-
const imageDimensions = constructRect(getContainedSize(imageBuffer));
94-
if (cropper) {
95-
cropper.width = imageDimensions.width * DPI;
96-
cropper.height = imageDimensions.height * DPI;
97-
cropper.style.width = `${imageDimensions.width}px`;
98-
cropper.style.height = `${imageDimensions.height}px`;
99-
const ctx = cropper.getContext('2d');
100-
if (ctx) {
101-
ctx.scale(DPI, DPI);
102-
}
99+
function resizeCanvas(canvasRef: Hooks.Ref<HTMLCanvasElement>, imageDimensions: Rect): void {
100+
const canvas = canvasRef.current;
101+
if (!canvas) {
102+
return;
103103
}
104104

105-
const cropButton = cropContainerRef.current;
106-
if (cropButton) {
107-
cropButton.style.width = `${imageDimensions.width}px`;
108-
cropButton.style.height = `${imageDimensions.height}px`;
105+
canvas.width = imageDimensions.width * DPI;
106+
canvas.height = imageDimensions.height * DPI;
107+
canvas.style.width = `${imageDimensions.width}px`;
108+
canvas.style.height = `${imageDimensions.height}px`;
109+
const ctx = canvas.getContext('2d');
110+
if (ctx) {
111+
ctx.scale(DPI, DPI);
112+
}
113+
}
114+
115+
function resize(): void {
116+
const imageDimensions = constructRect(getContainedSize(imageBuffer));
117+
118+
resizeCanvas(croppingRef, imageDimensions);
119+
resizeCanvas(annotatingRef, imageDimensions);
120+
121+
const cropContainer = cropContainerRef.current;
122+
if (cropContainer) {
123+
cropContainer.style.width = `${imageDimensions.width}px`;
124+
cropContainer.style.height = `${imageDimensions.height}px`;
109125
}
110126

111127
setCroppingRect({ startX: 0, startY: 0, endX: imageDimensions.width, endY: imageDimensions.height });
@@ -141,6 +157,7 @@ export function ScreenshotEditorFactory({
141157
}, [croppingRect]);
142158

143159
function onGrabButton(e: Event, corner: string): void {
160+
setIsAnnotating(false);
144161
setConfirmCrop(false);
145162
setIsResizing(true);
146163
const handleMouseMove = makeHandleMouseMove(corner);
@@ -247,7 +264,49 @@ export function ScreenshotEditorFactory({
247264
DOCUMENT.addEventListener('mouseup', handleMouseUp);
248265
}
249266

250-
function submit(): void {
267+
function onAnnotateStart(): void {
268+
if (!isAnnotating) {
269+
return;
270+
}
271+
272+
const handleMouseMove = (moveEvent: MouseEvent): void => {
273+
const annotateCanvas = annotatingRef.current;
274+
if (annotateCanvas) {
275+
const rect = annotateCanvas.getBoundingClientRect();
276+
277+
const x = moveEvent.clientX - rect.x;
278+
const y = moveEvent.clientY - rect.y;
279+
280+
const ctx = annotateCanvas.getContext('2d');
281+
if (ctx) {
282+
ctx.lineTo(x, y);
283+
ctx.stroke();
284+
ctx.beginPath();
285+
ctx.moveTo(x, y);
286+
}
287+
}
288+
};
289+
290+
const handleMouseUp = (): void => {
291+
const ctx = annotatingRef.current?.getContext('2d');
292+
// starts a new path so on next mouse down, the lines won't connect
293+
if (ctx) {
294+
ctx.beginPath();
295+
}
296+
297+
// draws the annotation onto the image buffer
298+
// TODO: move this to a better place
299+
applyAnnotation();
300+
301+
DOCUMENT.removeEventListener('mousemove', handleMouseMove);
302+
DOCUMENT.removeEventListener('mouseup', handleMouseUp);
303+
};
304+
305+
DOCUMENT.addEventListener('mousemove', handleMouseMove);
306+
DOCUMENT.addEventListener('mouseup', handleMouseUp);
307+
}
308+
309+
function applyCrop(): void {
251310
const cutoutCanvas = DOCUMENT.createElement('canvas');
252311
const imageBox = constructRect(getContainedSize(imageBuffer));
253312
const croppingBox = constructRect(croppingRect);
@@ -277,7 +336,32 @@ export function ScreenshotEditorFactory({
277336
imageBuffer.style.width = `${croppingBox.width}px`;
278337
imageBuffer.style.height = `${croppingBox.height}px`;
279338
ctx.drawImage(cutoutCanvas, 0, 0);
280-
resizeCropper();
339+
resize();
340+
}
341+
}
342+
343+
function applyAnnotation(): void {
344+
// draw the annotations onto the image (ie "squash" the canvases)
345+
const imageCtx = imageBuffer.getContext('2d');
346+
const annotateCanvas = annotatingRef.current;
347+
if (imageCtx && annotateCanvas) {
348+
imageCtx.drawImage(
349+
annotateCanvas,
350+
0,
351+
0,
352+
annotateCanvas.width,
353+
annotateCanvas.height,
354+
0,
355+
0,
356+
imageBuffer.width,
357+
imageBuffer.height,
358+
);
359+
360+
// clear the annotation canvas
361+
const annotateCtx = annotateCanvas.getContext('2d');
362+
if (annotateCtx) {
363+
annotateCtx.clearRect(0, 0, annotateCanvas.width, annotateCanvas.height);
364+
}
281365
}
282366
}
283367

@@ -303,7 +387,7 @@ export function ScreenshotEditorFactory({
303387
(dialog.el as HTMLElement).style.display = 'block';
304388
const container = canvasContainerRef.current;
305389
container?.appendChild(imageBuffer);
306-
resizeCropper();
390+
resize();
307391
}, []),
308392
onError: hooks.useCallback(error => {
309393
(dialog.el as HTMLElement).style.display = 'block';
@@ -314,11 +398,32 @@ export function ScreenshotEditorFactory({
314398
return (
315399
<div class="editor">
316400
<style nonce={options.styleNonce} dangerouslySetInnerHTML={styles} />
401+
{options._experiments.annotations && (
402+
<div class="editor__tool-container">
403+
<button
404+
class="editor__pen-tool"
405+
style={{
406+
background: isAnnotating
407+
? 'var(--button-primary-background, var(--accent-background))'
408+
: 'var(--button-background, var(--background))',
409+
color: isAnnotating
410+
? 'var(--button-primary-foreground, var(--accent-foreground))'
411+
: 'var(--button-foreground, var(--foreground))',
412+
}}
413+
onClick={e => {
414+
e.preventDefault();
415+
setIsAnnotating(!isAnnotating);
416+
}}
417+
>
418+
<PenIcon />
419+
</button>
420+
</div>
421+
)}
317422
<div class="editor__canvas-container" ref={canvasContainerRef}>
318-
<div class="editor__crop-container" style={{ position: 'absolute', zIndex: 1 }} ref={cropContainerRef}>
423+
<div class="editor__crop-container" style={{ zIndex: isAnnotating ? 1 : 2 }} ref={cropContainerRef}>
319424
<canvas
320425
onMouseDown={onDragStart}
321-
style={{ position: 'absolute', cursor: confirmCrop ? 'move' : 'auto' }}
426+
style={{ cursor: confirmCrop ? 'move' : 'auto' }}
322427
ref={croppingRef}
323428
></canvas>
324429
<CropCorner
@@ -373,7 +478,7 @@ export function ScreenshotEditorFactory({
373478
<button
374479
onClick={e => {
375480
e.preventDefault();
376-
submit();
481+
applyCrop();
377482
setConfirmCrop(false);
378483
}}
379484
class="btn btn--primary"
@@ -382,6 +487,12 @@ export function ScreenshotEditorFactory({
382487
</button>
383488
</div>
384489
</div>
490+
<canvas
491+
class="editor__annotation"
492+
onMouseDown={onAnnotateStart}
493+
style={{ zIndex: isAnnotating ? '2' : '1' }}
494+
ref={annotatingRef}
495+
></canvas>
385496
</div>
386497
</div>
387498
);

packages/feedback/src/screenshot/components/ScreenshotInput.css.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export function createScreenshotInputStyles(styleNonce?: string): HTMLStyleEleme
1515
padding-top: 65px;
1616
padding-bottom: 65px;
1717
flex-grow: 1;
18+
position: relative;
1819
1920
background-color: ${surface200};
2021
background-image: repeating-linear-gradient(
@@ -44,14 +45,18 @@ export function createScreenshotInputStyles(styleNonce?: string): HTMLStyleEleme
4445
4546
.editor__canvas-container canvas {
4647
object-fit: contain;
47-
position: relative;
48+
position: absolute;
49+
}
50+
51+
.editor__crop-container {
52+
position: absolute;
4853
}
4954
5055
.editor__crop-btn-group {
5156
padding: 8px;
5257
gap: 8px;
5358
border-radius: var(--menu-border-radius, 6px);
54-
background: var(--button-primary-background, var(--background));
59+
background: var(--button-background, var(--background));
5560
width: 175px;
5661
position: absolute;
5762
}
@@ -84,6 +89,19 @@ export function createScreenshotInputStyles(styleNonce?: string): HTMLStyleEleme
8489
border-left: none;
8590
border-top: none;
8691
}
92+
.editor__tool-container {
93+
position: absolute;
94+
padding: 10px 0px;
95+
top: 0;
96+
}
97+
.editor__pen-tool {
98+
height: 30px;
99+
display: flex;
100+
justify-content: center;
101+
align-items: center;
102+
border: var(--button-border, var(--border));
103+
border-radius: var(--button-border-radius, 6px);
104+
}
87105
`;
88106

89107
if (styleNonce) {

0 commit comments

Comments
 (0)