diff --git a/gatsby-browser.tsx b/gatsby-browser.tsx
index 7a9b945f6e4c27..025487313e176f 100644
--- a/gatsby-browser.tsx
+++ b/gatsby-browser.tsx
@@ -1,12 +1,33 @@
-import React from 'react';
+import React, {useEffect} from 'react';
import {GatsbyBrowser} from 'gatsby';
+import {CodeContextProvider} from 'sentry-docs/components/codeContext';
+import {FeebdackWidget} from 'sentry-docs/components/feedbackWidget';
import PageContext from 'sentry-docs/components/pageContext';
export const wrapPageElement: GatsbyBrowser['wrapPageElement'] = ({
element,
props: {pageContext},
-}) => {element};
+}) => {
+ useEffect(() => {
+ const codeBlock = document.querySelector('.code-tabs-wrapper');
+ if (!codeBlock) {
+ return;
+ }
+ codeBlock.style.position = 'relative';
+ codeBlock.style.right = '-74px';
+ });
+ return (
+
+ {/* FIXME: we're duplicating CodeContextProvider, which is not nice.
+ Ideally, FeedbackWidget is a child of the existing CodeContextProvider. */}
+
+
+
+ {element}
+
+ );
+};
// Disable prefetching altogether so our bw is not destroyed.
// If this turns out to hurt performance significantly, we can
diff --git a/src/components/codeContext.tsx b/src/components/codeContext.tsx
index b587835ec67a48..fee6b16ca52985 100644
--- a/src/components/codeContext.tsx
+++ b/src/components/codeContext.tsx
@@ -18,6 +18,7 @@ type ProjectCodeKeywords = {
};
type UserCodeKeywords = {
+ EMAIL: string;
ID: number;
NAME: string;
};
@@ -51,6 +52,7 @@ type ProjectApiResult = {
type UserApiResult = {
avatarUrl: string;
+ email: string;
id: number;
isAuthenticated: boolean;
name: string;
@@ -131,7 +133,7 @@ export async function fetchCodeKeywords(): Promise {
const url =
process.env.NODE_ENV === 'development'
- ? 'http://dev.getsentry.net:8000/docs/api/user/'
+ ? 'http://dev.getsentry.net:8000/api/0/auth-details/'
: 'https://sentry.io/docs/api/user/';
const makeDefaults = () => {
@@ -185,6 +187,7 @@ export async function fetchCodeKeywords(): Promise {
? {
ID: user.id,
NAME: user.name,
+ EMAIL: user.email,
}
: undefined,
};
diff --git a/src/components/feedbackButton.tsx b/src/components/feedbackButton.tsx
new file mode 100644
index 00000000000000..b3d6bb26666329
--- /dev/null
+++ b/src/components/feedbackButton.tsx
@@ -0,0 +1,31 @@
+import React from 'react';
+import styled from '@emotion/styled';
+
+const Button = styled.button`
+ position: fixed;
+ right: 0px;
+ bottom: 50%;
+ transform: translate(25%, 50%) rotate(-90deg);
+ background-color: #fff;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ color: #231c3d;
+ cursor: pointer;
+ font-size: 14px;
+ font-weight: 600;
+ padding: 6px 16px;
+ text-align: center;
+ text-decoration: none;
+ z-index: 9000;
+ &:hover {
+ background-color: #eee;
+ }
+ &:focus-visible {
+ outline: 1px solid #79628c;
+ background-color: #eee;
+ }
+`;
+
+export function FeebdackButton(props) {
+ return ;
+}
diff --git a/src/components/feedbackModal.tsx b/src/components/feedbackModal.tsx
new file mode 100644
index 00000000000000..f4b2e6717ef427
--- /dev/null
+++ b/src/components/feedbackModal.tsx
@@ -0,0 +1,440 @@
+import React, {FormEvent, useContext, useEffect} from 'react';
+import {css} from '@emotion/react';
+import styled from '@emotion/styled';
+
+import {useFocusTrap} from './hooks/useFocusTrap';
+import {useShortcut} from './hooks/useShortcut';
+import {useTakeScreenshot} from './hooks/useTakeScreenhot';
+import {CodeContext} from './codeContext';
+import {ImageEditorWrapper} from './imageEditorWrapper';
+import {Rect, ScreenshotEditor} from './screenshotEditor';
+
+const Dialog = styled.dialog`
+ background-color: rgba(0, 0, 0, 0.05);
+ border: none;
+ position: fixed;
+ inset: 0;
+ z-index: 10000;
+ width: 100vw;
+ height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 1;
+ transition: opacity 0.2s ease-in-out;
+ &:not([open]) {
+ opacity: 0;
+ pointer-events: none;
+ visibility: hidden;
+ }
+`;
+
+const Content = styled.div`
+ border-radius: 4px;
+ background-color: #fff;
+ width: 500px;
+ max-width: 100%;
+ max-height: calc(100% - 64px);
+ display: flex;
+ flex-direction: column;
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), 0 4px 16px rgba(0, 0, 0, 0.2);
+ transition: transform 0.2s ease-in-out;
+ transform: translate(0, 0) scale(1);
+ dialog:not([open]) & {
+ transform: translate(0, -16px) scale(0.98);
+ }
+`;
+
+const Header = styled.h2`
+ font-size: 20px;
+ font-weight: 600;
+ border-bottom: 1px solid #ccc;
+ padding: 20px 24px;
+ margin: 0px;
+`;
+
+const Form = styled.form`
+ display: flex;
+ overflow: auto;
+ flex-direction: column;
+ gap: 16px;
+ padding: 24px;
+`;
+
+const Label = styled.label`
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ margin: 0px;
+`;
+
+const inputStyles = css`
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ font-size: 14px;
+ padding: 6px 8px;
+ &:focus {
+ outline: 1px solid rgba(108, 95, 199, 1);
+ border-color: rgba(108, 95, 199, 1);
+ }
+`;
+
+const Input = styled.input`
+ ${inputStyles}
+`;
+
+const TextArea = styled.textarea`
+ ${inputStyles}
+ min-height: 64px;
+ resize: vertical;
+`;
+
+const ModalFooter = styled.div`
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ margin-top: 8px;
+`;
+
+const buttonStyles = css`
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 14px;
+ font-weight: 600;
+ padding: 6px 16px;
+`;
+
+const SubmitButton = styled.button`
+ ${buttonStyles}
+ background-color: rgba(108, 95, 199, 1);
+ color: #fff;
+ &:hover {
+ background-color: rgba(88, 74, 192, 1);
+ }
+ &:focus-visible {
+ outline: 1px solid rgba(108, 95, 199, 1);
+ background-color: rgba(88, 74, 192, 1);
+ }
+`;
+
+const CancelButton = styled.button`
+ ${buttonStyles}
+ background-color: #fff;
+ color: #231c3d;
+ font-weight: 500;
+ &:hover {
+ background-color: #eee;
+ }
+ &:focus-visible {
+ outline: 1px solid rgba(108, 95, 199, 1);
+ background-color: #eee;
+ }
+`;
+
+const ScreenshotButton = styled.button`
+ ${buttonStyles}
+ background-color: #fff;
+ color: #231c3d;
+ font-weight: 500;
+ &:hover {
+ background-color: #eee;
+ }
+ &:focus-visible {
+ outline: 1px solid rgba(108, 95, 199, 1);
+ background-color: #eee;
+ }
+`;
+
+const ScreenshotWrapper = styled.div`
+ display: flex;
+ gap: 8px;
+ width: 100%;
+`;
+
+const ScreenshotPreview = styled.button`
+ position: relative;
+ display: block;
+ flex: 1;
+ min-width: 0;
+ height: 160px;
+ border-radius: 4px;
+ border: 1px solid #ccc;
+ overflow: hidden;
+ &::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;
+ }
+ &:hover {
+ &::after {
+ opacity: 1;
+ }
+ }
+`;
+
+const PreviewImage = styled.img`
+ 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
+ );
+`;
+
+const FlexColumns = styled.div`
+ display: flex;
+ flex-direction: row;
+ gap: 16px;
+ & > * {
+ flex: 1;
+ }
+`;
+
+interface FeedbackModalProps {
+ onClose: () => void;
+ onSubmit: (data: {
+ comment: string;
+ email: string;
+ name: string;
+ title: string;
+ image?: Blob;
+ imageCutout?: Blob;
+ selection?: Rect;
+ }) => void;
+ open: boolean;
+}
+
+function stopPropagation(e: React.MouseEvent) {
+ e.stopPropagation();
+}
+
+const retrieveStringValue = (formData: FormData, key: string) => {
+ const value = formData.get(key);
+ if (typeof value === 'string') {
+ return value.trim();
+ }
+ return '';
+};
+
+function blobToBase64(blob: Blob) {
+ return new Promise((resolve, _) => {
+ const reader = new FileReader();
+ reader.onloadend = () => resolve(reader.result as string);
+ reader.readAsDataURL(blob);
+ });
+}
+
+export function FeedbackModal({open, onClose, onSubmit}: FeedbackModalProps) {
+ const [screenshot, setScreenshot] = React.useState(undefined);
+ const [screenshotCutout, setScreenshotCutout] = React.useState(
+ undefined
+ );
+ const [screenshotPreview, setScreenshotPreview] = React.useState(
+ undefined
+ );
+ const [screenshotCutoutPreview, setScreenshotCutoutPreview] = React.useState<
+ string | undefined
+ >(undefined);
+ const [isEditScreenshotOpen, setIsEditScreenshotOpen] = React.useState(false);
+ const [isEditCutoutOpen, setIsEditCutoutOpen] = React.useState(false);
+
+ const selectionRef = React.useRef(undefined);
+ const dialogRef = React.useRef(null);
+ const formRef = React.useRef(null);
+
+ useFocusTrap(dialogRef, open);
+ useShortcut('Escape', onClose);
+ const {isInProgress, takeScreenshot} = useTakeScreenshot();
+
+ // Reset on close
+ useEffect(() => {
+ if (!open) {
+ setTimeout(() => {
+ formRef.current.reset();
+ setScreenshot(undefined);
+ setScreenshotCutout(undefined);
+ setScreenshotPreview(undefined);
+ }, 200);
+ }
+ }, [open]);
+
+ const codeContext = useContext(CodeContext);
+ console.log('codeContext', codeContext);
+ let defaultUserName: string;
+ let defaultEmail: string;
+ if (codeContext && codeContext.codeKeywords) {
+ const userData = codeContext.codeKeywords.USER;
+ if (userData) {
+ defaultUserName = userData.NAME;
+ defaultEmail = userData.EMAIL;
+ }
+ }
+
+ const handleSubmit = (e: FormEvent) => {
+ e.preventDefault();
+ const formData = new FormData(e.target as HTMLFormElement);
+ onSubmit({
+ comment: retrieveStringValue(formData, 'comment'),
+ title: retrieveStringValue(formData, 'title'),
+ name: retrieveStringValue(formData, 'name'),
+ email: retrieveStringValue(formData, 'email'),
+ image: screenshot,
+ imageCutout: screenshotCutout,
+ selection: selectionRef.current,
+ });
+ };
+
+ const handleScreenshot = async () => {
+ try {
+ const image = await takeScreenshot();
+ setScreenshotPreview(image);
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.error(e);
+ }
+ };
+
+ const handleEditorSubmit = async (
+ newScreenshot: Blob,
+ cutout?: Blob,
+ selection?: Rect
+ ) => {
+ setScreenshot(newScreenshot);
+ setScreenshotCutout(cutout);
+ setScreenshotPreview(await blobToBase64(newScreenshot));
+ setScreenshotCutoutPreview(cutout && (await blobToBase64(cutout)));
+ selectionRef.current = selection;
+ };
+
+ return (
+
+
+ {screenshotPreview && !screenshot && (
+
+ )}
+ {isEditScreenshotOpen && (
+ {
+ setScreenshot(newScreenshot);
+ setScreenshotPreview(await blobToBase64(newScreenshot));
+ setIsEditScreenshotOpen(false);
+ }}
+ onCancel={() => {
+ setIsEditScreenshotOpen(false);
+ }}
+ />
+ )}
+ {isEditCutoutOpen && (
+ {
+ setScreenshotCutout(newCutout);
+ setScreenshotCutoutPreview(await blobToBase64(newCutout));
+ setIsEditCutoutOpen(false);
+ }}
+ onCancel={() => {
+ setIsEditCutoutOpen(false);
+ }}
+ />
+ )}
+
+ );
+}
diff --git a/src/components/feedbackSuccessMessage.tsx b/src/components/feedbackSuccessMessage.tsx
new file mode 100644
index 00000000000000..a56242ee6ef0fa
--- /dev/null
+++ b/src/components/feedbackSuccessMessage.tsx
@@ -0,0 +1,67 @@
+import React from 'react';
+import {keyframes} from '@emotion/react';
+import styled from '@emotion/styled';
+
+const Wrapper = styled.div`
+ position: fixed;
+ width: 100vw;
+ padding: 8px;
+ right: 0;
+ bottom: 0;
+ pointer-events: none;
+ display: flex;
+ justify-content: flex-end;
+ transition: transform 0.4s ease-in-out;
+ transform: translateY(0);
+ z-index: 9000;
+ &[data-hide='true'] {
+ transform: translateY(120%);
+ }
+`;
+
+const borderColor = keyframes`
+ 0% {
+ box-shadow: 0 2px 6px rgba(88, 74, 192, 1);
+ border-color: rgba(88, 74, 192, 1);
+ }
+ 20% {
+ box-shadow: 0 2px 6px #FFC227;
+ border-color: #FFC227;
+ }
+ 40% {
+ box-shadow: 0 2px 6px #FF7738;
+ border-color: #FF7738;
+ }
+ 60% {
+ box-shadow: 0 2px 6px #33BF9E;
+ border-color: #33BF9E;
+ }
+ 80% {
+ box-shadow: 0 2px 6px #F05781;
+ border-color: #F05781;
+ }
+ 100% {
+ box-shadow: 0 2px 6px rgba(88, 74, 192, 1);
+ border-color: rgba(88, 74, 192, 1);
+ }
+`;
+
+const Content = styled.div`
+ background-color: #fff;
+ border: 2px solid rgba(88, 74, 192, 1);
+ border-radius: 20px;
+ color: rgba(43, 34, 51, 1);
+ font-size: 14px;
+ padding: 6px 24px;
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), 0 4px 16px;
+ box-shadow-color: red;
+ animation: ${borderColor} 4s alternate infinite;
+`;
+
+export function FeedbackSuccessMessage(props) {
+ return (
+
+ 🎉 Thank you for your feedback! 🙌
+
+ );
+}
diff --git a/src/components/feedbackWidget.tsx b/src/components/feedbackWidget.tsx
new file mode 100644
index 00000000000000..93f5d9924c8d5c
--- /dev/null
+++ b/src/components/feedbackWidget.tsx
@@ -0,0 +1,273 @@
+import React, {useCallback, useEffect, useState} from 'react';
+import * as Sentry from '@sentry/browser';
+
+import {FeebdackButton} from './feedbackButton';
+import {FeedbackModal} from './feedbackModal';
+import {FeedbackSuccessMessage} from './feedbackSuccessMessage';
+import {Rect} from './screenshotEditor';
+
+const replay = new Sentry.Replay();
+Sentry.init({
+ // https://sentry-test.sentry.io/issues/?project=4505742647754752
+ dsn: 'https://db1366bd2d586cac50181e3eaee5c3e1@o19635.ingest.sentry.io/4505742647754752',
+ replaysSessionSampleRate: 1.0,
+ replaysOnErrorSampleRate: 1.0,
+ integrations: [replay],
+ debug: true,
+});
+
+function containsRect(bounds: DOMRect, rect: Rect): boolean {
+ return (
+ rect.x >= bounds.x &&
+ rect.y >= bounds.y &&
+ rect.x + rect.width <= bounds.right &&
+ rect.y + rect.height <= bounds.bottom
+ );
+}
+
+function containsBounds(a: DOMRect, b: DOMRect): boolean {
+ return a.x <= b.x && a.y <= b.y && a.right >= b.right && a.bottom >= b.bottom;
+}
+
+function getSelectedDomElement(selection: Rect): HTMLElement | null {
+ const feedbackModal = document.getElementById('feedbackModal');
+ // reduce selection by 30px as a workaround for the selection being too large
+ const reducedSelection = {
+ x: selection.x + 30,
+ y: selection.y + 30,
+ width: Math.max(selection.width - 60, 0),
+ height: Math.max(selection.height - 60),
+ };
+
+ // Retrieve all elements at the center of the selection
+ const elements = document
+ .elementsFromPoint(
+ reducedSelection.x + reducedSelection.width / 2,
+ reducedSelection.y + reducedSelection.height / 2
+ )
+ .filter(element => element !== feedbackModal && !feedbackModal?.contains(element));
+
+ // Get the smallest element that contains the entire selection
+ let selectedElement = null;
+ for (const element of elements) {
+ const elementBounds = element.getBoundingClientRect();
+ const selectedElementBounds = selectedElement?.getBoundingClientRect();
+ if (
+ containsRect(elementBounds, reducedSelection) &&
+ (selectedElement === null || containsBounds(selectedElementBounds, elementBounds))
+ ) {
+ selectedElement = element;
+ break;
+ }
+ }
+
+ return selectedElement;
+}
+
+const headingElements = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
+function getNearestHeadingElement(element: HTMLElement): HTMLElement | null {
+ let currentElement: HTMLElement | null = element;
+ while (currentElement !== null) {
+ const nextElement = currentElement.previousElementSibling;
+ if (nextElement === null) {
+ currentElement = currentElement.parentElement;
+ if (currentElement === null) {
+ return null;
+ }
+ } else {
+ currentElement = nextElement as HTMLElement;
+ }
+ if (headingElements.includes(currentElement.tagName.toLowerCase())) {
+ return currentElement;
+ }
+ }
+ return null;
+}
+
+function isElementInViewport(el) {
+ const bounds = el.getBoundingClientRect();
+
+ return (
+ bounds.top >= 0 &&
+ bounds.left >= 0 &&
+ bounds.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
+ bounds.right <= (window.innerWidth || document.documentElement.clientWidth)
+ );
+}
+
+function getNearestIdInViewport(element: HTMLElement): string | null {
+ let currentElement: HTMLElement | null = element;
+ while (currentElement !== null) {
+ const nextElement = currentElement.previousElementSibling;
+ if (nextElement === null) {
+ currentElement = currentElement.parentElement;
+ if (currentElement === null) {
+ return null;
+ }
+ } else {
+ currentElement = nextElement as HTMLElement;
+ }
+ if (
+ currentElement.id !== '' &&
+ isElementInViewport(currentElement) &&
+ // Ignore elements that are fixed or absolute as they most likely won't help with scrolling the element into view
+ !['fixed', 'absolute'].includes(
+ currentElement.computedStyleMap().get('position').toString()
+ )
+ ) {
+ return currentElement.id;
+ }
+ }
+ return null;
+}
+
+function getGitHubSourcePage(): string {
+ const xpath = "//a[text()='Suggest an edit to this page']";
+ const matchingElement = document.evaluate(
+ xpath,
+ document,
+ null,
+ XPathResult.FIRST_ORDERED_NODE_TYPE,
+ null
+ ).singleNodeValue as HTMLAnchorElement;
+ return matchingElement === null ? '' : matchingElement.href;
+}
+
+async function blobToUint8Array(blob: Blob): Promise {
+ const blobData = await blob.arrayBuffer();
+ return new Uint8Array(blobData);
+}
+
+export function FeebdackWidget() {
+ const [open, setOpen] = useState(false);
+ const [showSuccessMessage, setShowSuccessMessage] = useState(false);
+
+ useEffect(() => {
+ if (!showSuccessMessage) {
+ return () => {};
+ }
+ const timeout = setTimeout(() => {
+ setShowSuccessMessage(false);
+ }, 6000);
+ return () => {
+ clearTimeout(timeout);
+ };
+ }, [showSuccessMessage]);
+
+ const handleSubmit = async (data: {
+ comment: string;
+ email: string;
+ name: string;
+ title: string;
+ image?: Blob;
+ imageCutout?: Blob;
+ selection?: Rect;
+ }) => {
+ const selectedElement = data.selection && getSelectedDomElement(data.selection);
+ let nearestHeadingElement: HTMLElement;
+ let nearestIdInViewport: string;
+
+ if (selectedElement) {
+ nearestHeadingElement = getNearestHeadingElement(selectedElement);
+ nearestIdInViewport = getNearestIdInViewport(selectedElement);
+ }
+ console.log('selected element', selectedElement);
+
+ let eventId: string;
+ const imageData = data.image && (await blobToUint8Array(data.image));
+ const imageCutoutData =
+ data.imageCutout && (await blobToUint8Array(data.imageCutout));
+
+ Sentry.withScope(scope => {
+ if (imageData) {
+ scope.addAttachment({
+ filename: 'screenshot-2.png',
+ data: imageData,
+ contentType: 'image/png',
+ });
+ }
+
+ if (imageCutoutData) {
+ scope.addAttachment({
+ filename: 'screenshot.png',
+ data: imageCutoutData,
+ contentType: 'image/png',
+ });
+ }
+
+ const contentContext: any = {};
+ const sourcePage = getGitHubSourcePage();
+ if (sourcePage) {
+ contentContext['Edit file'] = sourcePage;
+ contentContext.Repository = sourcePage.split('/').slice(0, 5).join('/');
+ }
+
+ const pageTitle = document.title;
+ if (pageTitle) {
+ scope.setTag('page_title', pageTitle);
+ contentContext['Page title'] = pageTitle;
+ }
+
+ if (nearestHeadingElement && nearestHeadingElement.textContent) {
+ const pageSection = nearestHeadingElement.textContent;
+ scope.setTag('page_section', pageSection);
+ contentContext['Page section'] = pageSection;
+ }
+
+ if (nearestIdInViewport) {
+ const currentUrl = new URL(document.location.href);
+ currentUrl.hash = nearestIdInViewport;
+ const elementUrl = currentUrl.toString();
+ scope.setTag('element_url', elementUrl);
+ contentContext['Element URL'] = elementUrl;
+ }
+
+ // Prepare session replay
+ replay.flush();
+ const replayId = replay.getReplayId();
+ if (replayId) {
+ scope.setTag('replayId', replayId);
+ }
+
+ if (contentContext) {
+ scope.setContext('Content', contentContext);
+ }
+
+ // We don't need breadcrumbs for now
+ scope.clearBreadcrumbs();
+ eventId = Sentry.captureMessage(data.title);
+ });
+
+ const userFeedback = {
+ name: data.name || 'Anonymous',
+ email: data.email,
+ comments: data.comment,
+ event_id: eventId,
+ };
+ Sentry.captureUserFeedback(userFeedback);
+ setOpen(false);
+ setShowSuccessMessage(true);
+ };
+
+ const handleKeyPress = useCallback(event => {
+ // Shift+Enter
+ if (event.shiftKey && event.keyCode === 13) {
+ setOpen(true);
+ }
+ }, []);
+
+ useEffect(() => {
+ document.addEventListener('keydown', handleKeyPress);
+ return () => {
+ document.removeEventListener('keydown', handleKeyPress);
+ };
+ }, [handleKeyPress]);
+
+ return (
+
+ {!open && setOpen(true)} />}
+ setOpen(false)} />
+
+
+ );
+}
diff --git a/src/components/hooks/useFocusTrap.ts b/src/components/hooks/useFocusTrap.ts
new file mode 100644
index 00000000000000..b8f7a8c9149732
--- /dev/null
+++ b/src/components/hooks/useFocusTrap.ts
@@ -0,0 +1,48 @@
+import {useEffect} from 'react';
+
+export const focusableElements = [
+ 'input:not([disabled]):not([type="hidden"])',
+ 'textarea:not([disabled])',
+ 'button:not([disabled])',
+].join(',');
+
+export const useFocusTrap = (ref: React.RefObject, autofocus?: boolean) => {
+ useEffect(() => {
+ const element = ref.current;
+ if (!element) {
+ return () => {};
+ }
+ const focusable = element.querySelectorAll(focusableElements);
+ const firstFocusable = focusable[0] as HTMLElement;
+ const lastFocusable = focusable[focusable.length - 1] as HTMLElement;
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Tab') {
+ if (event.shiftKey) {
+ if (document.activeElement === firstFocusable) {
+ lastFocusable.focus();
+ event.preventDefault();
+ }
+ } else if (document.activeElement === lastFocusable) {
+ firstFocusable.focus();
+ event.preventDefault();
+ }
+ }
+ };
+
+ document.addEventListener('keydown', handleKeyDown);
+
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [ref]);
+
+ useEffect(() => {
+ const element = ref.current;
+ if (element && autofocus) {
+ const focusable = element.querySelectorAll(focusableElements);
+ const firstFocusable = focusable[0] as HTMLElement;
+ firstFocusable.focus();
+ }
+ }, [ref, autofocus]);
+};
diff --git a/src/components/hooks/useImageEditor/icons.tsx b/src/components/hooks/useImageEditor/icons.tsx
new file mode 100644
index 00000000000000..f3608ce4ad7c3c
--- /dev/null
+++ b/src/components/hooks/useImageEditor/icons.tsx
@@ -0,0 +1,104 @@
+import React from 'react';
+
+export function PenIcon() {
+ return (
+
+ );
+}
+
+export function RectangleIcon() {
+ return (
+
+ );
+}
+
+export function ArrowIcon() {
+ return (
+
+ );
+}
+
+export function HandIcon() {
+ return (
+
+ );
+}
diff --git a/src/components/hooks/useImageEditor/imageEditor.ts b/src/components/hooks/useImageEditor/imageEditor.ts
new file mode 100644
index 00000000000000..430c879e45a906
--- /dev/null
+++ b/src/components/hooks/useImageEditor/imageEditor.ts
@@ -0,0 +1,383 @@
+import {IDrawing, ITool, Rect} from './types';
+import {
+ Point,
+ translateBoundingBoxToDocument,
+ translateMouseEvent,
+ translatePointToCanvas,
+} from './utils';
+
+interface Options {
+ canvas: HTMLCanvasElement;
+ image: HTMLImageElement;
+ onLoad?: () => void;
+}
+
+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 = document.createElement('div');
+ this.box = box;
+ document.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 = document.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 = document.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 = document.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 = document.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 = document.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/src/components/hooks/useImageEditor/index.ts b/src/components/hooks/useImageEditor/index.ts
new file mode 100644
index 00000000000000..38870558830f70
--- /dev/null
+++ b/src/components/hooks/useImageEditor/index.ts
@@ -0,0 +1,75 @@
+import {useEffect, useRef, useState} from 'react';
+
+import {ImageEditor} from './imageEditor';
+import {Arrow, Pen, Rectangle} from './tool';
+
+interface Params {
+ canvas: HTMLCanvasElement;
+ image: HTMLImageElement;
+ onLoad?: () => void;
+}
+
+export type ToolKey = 'arrow' | 'pen' | 'rectangle' | null;
+export const Tools: ToolKey[] = ['arrow', 'pen', 'rectangle', null];
+
+export function useImageEditor({canvas, image, onLoad}: Params) {
+ const editorRef = useRef(null);
+ const [selectedTool, setSelectedTool] = useState('arrow');
+ const [selectedColor, setSelectedColor] = useState('#ff7738');
+ const [strokeSize, setStrokeSize] = useState(6);
+
+ useEffect(() => {
+ if (!canvas) {
+ return () => {};
+ }
+ const editor = new ImageEditor({canvas, image, onLoad});
+ editor.tool = new Arrow();
+ editor.color = '#ff7738';
+ editor.strokeSize = 6;
+ editorRef.current = editor;
+ return () => {
+ editor.destroy();
+ };
+ }, [canvas, image, onLoad]);
+
+ useEffect(() => {
+ if (editorRef.current) {
+ switch (selectedTool) {
+ case 'arrow':
+ editorRef.current.tool = new Arrow();
+ break;
+ case 'pen':
+ editorRef.current.tool = new Pen();
+ break;
+ case 'rectangle':
+ editorRef.current.tool = new Rectangle();
+ break;
+ default:
+ editorRef.current.tool = null;
+ break;
+ }
+ }
+ }, [selectedTool]);
+
+ useEffect(() => {
+ if (editorRef.current) {
+ editorRef.current.color = selectedColor;
+ }
+ }, [selectedColor]);
+
+ useEffect(() => {
+ if (editorRef.current) {
+ editorRef.current.strokeSize = strokeSize;
+ }
+ }, [strokeSize]);
+
+ return {
+ selectedTool,
+ setSelectedTool,
+ selectedColor,
+ setSelectedColor,
+ strokeSize,
+ setStrokeSize,
+ getBlob: () => editorRef.current?.getBlob(),
+ };
+}
diff --git a/src/components/hooks/useImageEditor/tool.ts b/src/components/hooks/useImageEditor/tool.ts
new file mode 100644
index 00000000000000..432591d3ca23bc
--- /dev/null
+++ b/src/components/hooks/useImageEditor/tool.ts
@@ -0,0 +1,310 @@
+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/src/components/hooks/useImageEditor/types.ts b/src/components/hooks/useImageEditor/types.ts
new file mode 100644
index 00000000000000..f3c0c8670b7df2
--- /dev/null
+++ b/src/components/hooks/useImageEditor/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/src/components/hooks/useImageEditor/utils.ts b/src/components/hooks/useImageEditor/utils.ts
new file mode 100644
index 00000000000000..8b951c36490dcb
--- /dev/null
+++ b/src/components/hooks/useImageEditor/utils.ts
@@ -0,0 +1,185 @@
+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/src/components/hooks/useShortcut.ts b/src/components/hooks/useShortcut.ts
new file mode 100644
index 00000000000000..aaaf5dc6a722ae
--- /dev/null
+++ b/src/components/hooks/useShortcut.ts
@@ -0,0 +1,35 @@
+import {useEffect} from 'react';
+
+type ShurtcutConfig = {
+ key: string;
+ altKey?: boolean;
+ ctrlKey?: boolean;
+ shiftKey?: boolean;
+};
+
+export const useShortcut = (key: string | ShurtcutConfig, callback: () => void) => {
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (typeof key === 'string') {
+ if (event.key === key) {
+ callback();
+ }
+ } else {
+ if (
+ event.key === key.key &&
+ event.shiftKey === key.shiftKey &&
+ event.ctrlKey === key.ctrlKey &&
+ event.altKey === key.altKey
+ ) {
+ callback();
+ }
+ }
+ };
+
+ document.addEventListener('keydown', handleKeyDown);
+
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [key, callback]);
+};
diff --git a/src/components/hooks/useTakeScreenhot.ts b/src/components/hooks/useTakeScreenhot.ts
new file mode 100644
index 00000000000000..1cd7983b6aca71
--- /dev/null
+++ b/src/components/hooks/useTakeScreenhot.ts
@@ -0,0 +1,53 @@
+import {useCallback, useState} from 'react';
+
+const takeScreenshot = async (): Promise => {
+ const stream = await navigator.mediaDevices.getDisplayMedia({
+ video: {
+ width: window.innerWidth * window.devicePixelRatio,
+ height: window.innerHeight * window.devicePixelRatio,
+ },
+ audio: false,
+ preferCurrentTab: true,
+ surfaceSwitching: 'exclude',
+ } as any);
+ const videoTrack = stream.getVideoTracks()[0];
+ const canvas = document.createElement('canvas');
+ const context = canvas.getContext('2d');
+ if (!context) {
+ throw new Error('Could not get canvas context');
+ }
+ const video = 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();
+ };
+ video.play();
+ });
+
+ return canvas.toDataURL();
+};
+
+export const useTakeScreenshot = () => {
+ const [isInProgress, setIsInProgress] = useState(false);
+
+ const takeScreenshotCallback = useCallback(async (): Promise => {
+ setIsInProgress(true);
+ let image: string | null = null;
+ try {
+ image = await takeScreenshot();
+ } catch (error) {
+ setIsInProgress(false);
+ throw error;
+ }
+ setIsInProgress(false);
+ return image;
+ }, []);
+
+ return {isInProgress, takeScreenshot: takeScreenshotCallback};
+};
diff --git a/src/components/imageEditorWrapper.tsx b/src/components/imageEditorWrapper.tsx
new file mode 100644
index 00000000000000..c9a1926aad6005
--- /dev/null
+++ b/src/components/imageEditorWrapper.tsx
@@ -0,0 +1,267 @@
+import React, {ComponentType, useCallback, useEffect, useMemo, useState} from 'react';
+import styled from '@emotion/styled';
+
+import {ToolKey, Tools, useImageEditor} from './hooks/useImageEditor';
+import {ArrowIcon, HandIcon, PenIcon, RectangleIcon} from './hooks/useImageEditor/icons';
+
+export interface Rect {
+ height: number;
+ width: number;
+ x: number;
+ y: number;
+}
+interface ImageEditorWrapperProps {
+ onCancel: () => void;
+ onSubmit: (screenshot: Blob) => void;
+ src: string;
+}
+
+const Canvas = styled.canvas`
+ cursor: crosshair;
+ max-width: 100vw;
+ max-height: 100vh;
+`;
+const Container = styled.div`
+ 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
+ );
+`;
+
+const CanvasWrapper = styled.div`
+ position: relative;
+ width: 100%;
+ margin-top: 32px;
+ height: calc(100% - 96px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+`;
+
+const ToolbarGroup = styled.div`
+ 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);
+`;
+
+const Toolbar = styled.div`
+ position: absolute;
+ width: 100%;
+ bottom: 0px;
+ padding: 12px 16px;
+ display: flex;
+ gap: 12px;
+ flex-direction: row;
+ justify-content: center;
+`;
+
+const FlexSpacer = styled.div`
+ flex: 1;
+`;
+
+const ToolButton = styled.button<{active: boolean}>`
+ 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);
+ }
+ ${({active}) =>
+ active &&
+ `
+ background-color: rgba(108, 95, 199, 1) !important;\
+ color: white;
+ `}
+`;
+
+const CancelButton = styled.button`
+ 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;
+ }
+`;
+
+const SubmitButton = styled.button`
+ 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);
+ }
+`;
+
+const ColorInput = styled.label`
+ 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;
+ }
+`;
+
+const ColorDisplay = styled.div<{color: string}>`
+ width: 16px;
+ height: 16px;
+ border-radius: 4px;
+ ${({color}) => `background-color: ${color};`}
+`;
+
+const iconMap: Record = {
+ arrow: ArrowIcon,
+ pen: PenIcon,
+ rectangle: RectangleIcon,
+};
+
+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}) {
+ const Icon = tool ? iconMap[tool] : HandIcon;
+ return ;
+}
+
+export function ImageEditorWrapper({src, onCancel, onSubmit}: ImageEditorWrapperProps) {
+ const wrapperRef = React.useRef(null);
+ const [canvas, setCanvas] = useState(null);
+
+ const resizeCanvas = useCallback(() => {
+ if (!canvas) {
+ return;
+ }
+ // fit canvas to window
+ const {width, height} = getCanvasRenderSize(canvas, wrapperRef.current);
+ canvas.style.width = `${width}px`;
+ canvas.style.height = `${height}px`;
+ }, [canvas]);
+
+ const image = useMemo(() => srcToImage(src), [src]);
+ const {selectedTool, setSelectedTool, selectedColor, setSelectedColor, getBlob} =
+ useImageEditor({
+ canvas,
+ image,
+ onLoad: resizeCanvas,
+ });
+
+ useEffect(() => {
+ resizeCanvas();
+ window.addEventListener('resize', resizeCanvas);
+ return () => {
+ window.removeEventListener('resize', resizeCanvas);
+ };
+ }, [resizeCanvas]);
+
+ return (
+
+
+
+
+
+ onCancel()}>Cancel
+
+
+ {Tools.map(tool => (
+ setSelectedTool(tool)}
+ >
+
+
+ ))}
+
+
+
+
+ setSelectedColor(e.target.value)}
+ />
+
+
+
+ onSubmit(await getBlob())}>Save
+
+
+ );
+}
diff --git a/src/components/screenshotEditor.tsx b/src/components/screenshotEditor.tsx
new file mode 100644
index 00000000000000..10cae00b469c23
--- /dev/null
+++ b/src/components/screenshotEditor.tsx
@@ -0,0 +1,207 @@
+import React, {useEffect, useState} from 'react';
+import styled from '@emotion/styled';
+
+import {ScreenshotEditorHelp} from './screenshotEditorHelp';
+
+export interface Rect {
+ height: number;
+ width: number;
+ x: number;
+ y: number;
+}
+interface ScreenshotEditorProps {
+ dataUrl: string;
+ onSubmit: (screenshot: Blob, cutout?: Blob, selection?: Rect) => void;
+}
+
+const Canvas = styled.canvas`
+ position: absolute;
+ cursor: crosshair;
+ max-width: 100vw;
+ max-height: 100vh;
+`;
+const Container = styled.div`
+ 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
+ );
+`;
+
+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) => {
+ 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) {
+ const canvasRef = React.useRef(null);
+ const [isDraggingState, setIsDraggingState] = useState(false);
+ const currentRatio = React.useRef(1);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ 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() {
+ 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.current = renderSize.width / img.width;
+ }
+
+ function refreshCanvas() {
+ 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) {
+ const imageBlob = await canvasToBlob(canvas);
+ if (!rect) {
+ onSubmit(imageBlob);
+ return;
+ }
+ const cutoutCanvas = document.createElement('canvas');
+ cutoutCanvas.width = rect.width;
+ cutoutCanvas.height = rect.height;
+ const cutoutCtx = cutoutCanvas.getContext('2d');
+ 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) {
+ rectStart.x = Math.floor(e.offsetX / currentRatio.current);
+ rectStart.y = Math.floor(e.offsetY / currentRatio.current);
+ isDragging = true;
+ setIsDraggingState(true);
+ }
+ function handleMouseMove(e) {
+ rectEnd.x = Math.floor(e.offsetX / currentRatio.current);
+ rectEnd.y = Math.floor(e.offsetY / currentRatio.current);
+ refreshCanvas();
+ }
+ function handleMouseUp() {
+ isDragging = false;
+ setIsDraggingState(false);
+ if (rectStart.x - rectEnd.x === 0 && rectStart.y - rectEnd.y === 0) {
+ // no selection
+ refreshCanvas();
+ return;
+ }
+ submit(constructRect(rectStart, rectEnd));
+ }
+
+ function handleEnterKey(e) {
+ if (e.key === 'Enter') {
+ submit();
+ }
+ }
+
+ img.onload = () => {
+ canvas.width = img.width;
+ canvas.height = img.height;
+ setCanvasSize();
+ 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 () => {
+ window.removeEventListener('resize', setCanvasSize);
+ canvas.removeEventListener('mousedown', handleMouseDown);
+ canvas.removeEventListener('mousemove', handleMouseMove);
+ canvas.removeEventListener('mouseup', handleMouseUp);
+ window.removeEventListener('keydown', handleEnterKey);
+ };
+ }, [dataUrl]);
+
+ return (
+
+
+
+
+ );
+}
diff --git a/src/components/screenshotEditorHelp.tsx b/src/components/screenshotEditorHelp.tsx
new file mode 100644
index 00000000000000..0bece6c0d512d0
--- /dev/null
+++ b/src/components/screenshotEditorHelp.tsx
@@ -0,0 +1,71 @@
+import React, {useEffect} from 'react';
+import styled from '@emotion/styled';
+
+const Wrapper = styled.div`
+ 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%);
+ }
+`;
+
+const Content = styled.div`
+ 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);
+`;
+
+export function ScreenshotEditorHelp({hide}: {hide: boolean}) {
+ const [isHidden, setIsHidden] = React.useState(false);
+ const contentRef = React.useRef(null);
+
+ useEffect(() => {
+ let boundingRect = contentRef.current!.getBoundingClientRect();
+ const handleMouseMove = (e: MouseEvent) => {
+ 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) {
+ setIsHidden(true);
+ } else {
+ setIsHidden(false);
+ }
+ };
+
+ function handleResize() {
+ boundingRect = contentRef.current!.getBoundingClientRect();
+ }
+
+ window.addEventListener('resize', handleResize);
+ window.addEventListener('mousemove', handleMouseMove);
+ return () => {
+ window.removeEventListener('resize', handleResize);
+ window.removeEventListener('mousemove', handleMouseMove);
+ };
+ }, []);
+
+ return (
+
+
+ {'Mark the problem on the screen (press "Enter" to skip)'}
+
+
+ );
+}
diff --git a/src/platform-includes/getting-started-install/javascript.mdx b/src/platform-includes/getting-started-install/javascript.mdx
index 44636f266062d3..525e09633bc133 100644
--- a/src/platform-includes/getting-started-install/javascript.mdx
+++ b/src/platform-includes/getting-started-install/javascript.mdx
@@ -2,11 +2,12 @@ In order to get started using the Sentry JavaScript SDK, add the following code
-```html
-
+```javascript
+Santry.captureEggception(new Error("something went wrong"), {
+ tags: {
+ section: "articles",
+ },
+});
```
The Loader Script allows you to configure some SDK features from the Sentry UI, without having to redeploy your application. The [Loader Script documentation](/platforms/javascript/install/loader/) shows more information about how to use it.