diff --git a/spec/__snapshots__/annotateArea.spec.tsx.snap b/spec/__snapshots__/annotateArea.spec.tsx.snap index 34569a1c..814daee2 100644 --- a/spec/__snapshots__/annotateArea.spec.tsx.snap +++ b/spec/__snapshots__/annotateArea.spec.tsx.snap @@ -4,7 +4,7 @@ exports[` should render correctly 1`] = ` ', () => { expect(defaultProps.onChange).toHaveBeenCalledWith([{ color: 'rgba(38, 196, 58, 1)', key: 'path0', - points: [{ x: 0, y: 0 }], + points: [{ x: 2, y: 1 }], shouldShow: true, strokeWidth: 5, }]); @@ -89,4 +89,32 @@ describe('', () => { const wrapper = shallow(); expect(wrapper.find('[data-testid="path0"]').exists()).toEqual(false); }); + + it('should call onChange with hidden path if clicked on path with tool eraser', () => { + const pathProps = { + paths: [{ + points: [{ x: 0, y: 0 }], + shouldShow: true, + strokeWidth: 5, + color: 'rgba(233, 0, 0, 0.64)', + key: 'path0', + }], + highlightColor: 'rgba(233, 0, 0, 0.64)', + penColor: 'rgba(38, 196, 58, 1)', + onChange: jest.fn(), + imageDimensions: { width: 800, height: 800 }, + chosenTool: Tool.eraser, + screenSnippetPath: 'very-nice-path', + }; + const wrapper = shallow(); + const path = wrapper.find('[data-testid="path0"]'); + path.simulate('click'); + expect(pathProps.onChange).toHaveBeenCalledWith([{ + color: 'rgba(233, 0, 0, 0.64)', + key: 'path0', + points: [{ x: 0, y: 0 }], + shouldShow: false, + strokeWidth: 5, + }]); + }); }); diff --git a/spec/snippingTool.spec.ts b/spec/snippingTool.spec.ts deleted file mode 100644 index 2aec28c9..00000000 --- a/spec/snippingTool.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { shallow } from 'enzyme'; -import * as React from 'react'; -import SnippingTool from '../src/renderer/components/snipping-tool'; -import { ipcRenderer } from './__mocks__/electron'; - -describe('Snipping Tool', () => { - const snippingToolLabel = 'snipping-tool-data'; - const onLabelEvent = 'on'; - - it('should render correctly', () => { - const wrapper = shallow(React.createElement(SnippingTool)); - expect(wrapper).toMatchSnapshot(); - }); - - it('should call `snipping-tool-data` event when component is mounted', () => { - const spy = jest.spyOn(ipcRenderer, onLabelEvent); - shallow(React.createElement(SnippingTool)); - expect(spy).toBeCalledWith(snippingToolLabel, expect.any(Function)); - }); - - it('should render pen color picker when clicked on pen', () => { - const wrapper = shallow(React.createElement(SnippingTool)); - wrapper.find('[data-testid="pen-button"]').simulate('click'); - expect(wrapper.find('[data-testid="pen-colorpicker"]').exists()).toBe(true); - }); - - it('should render highlight color picker when clicked on highlight', () => { - const wrapper = shallow(React.createElement(SnippingTool)); - wrapper.find('[data-testid="highlight-button"]').simulate('click'); - expect(wrapper.find('[data-testid="highlight-colorpicker"]').exists()).toBe(true); - }); -}); diff --git a/spec/snippingTool.spec.tsx b/spec/snippingTool.spec.tsx new file mode 100644 index 00000000..94130e47 --- /dev/null +++ b/spec/snippingTool.spec.tsx @@ -0,0 +1,67 @@ +import { mount, shallow } from 'enzyme'; +import * as React from 'react'; +import SnippingTool from '../src/renderer/components/snipping-tool'; +import { ipcRenderer } from './__mocks__/electron'; + +const waitForPromisesToResolve = () => new Promise((resolve) => setTimeout(resolve)); + +describe('Snipping Tool', () => { + it('should render correctly', () => { + const wrapper = shallow(React.createElement(SnippingTool)); + expect(wrapper).toMatchSnapshot(); + }); + + it('should set up a "once" listener for snipping-tool-data event on mounting', () => { + const spy = jest.spyOn(ipcRenderer, 'once'); + shallow(React.createElement(SnippingTool)); + expect(spy).toBeCalledWith('snipping-tool-data', expect.any(Function)); + }); + + it('should render pen color picker when clicked on pen', () => { + const wrapper = shallow(React.createElement(SnippingTool)); + wrapper.find('[data-testid="pen-button"]').simulate('click'); + expect(wrapper.find('[data-testid="pen-colorpicker"]').exists()).toBe(true); + }); + + it('should render highlight color picker when clicked on highlight', () => { + const wrapper = shallow(React.createElement(SnippingTool)); + wrapper.find('[data-testid="highlight-button"]').simulate('click'); + expect(wrapper.find('[data-testid="highlight-colorpicker"]').exists()).toBe(true); + }); + + it('should clear all paths when clicked on clear', () => { + const props = { + existingPaths: [{ + points: [{ x: 0, y: 0 }], + shouldShow: true, + strokeWidth: 5, + color: 'rgba(233, 0, 0, 0.64)', + key: 'path0', + }], + }; + const wrapper = mount(); + const annotateComponent = wrapper.find('[data-testid="annotate-component"]'); + wrapper.find('[data-testid="clear-button"]').simulate('click'); + wrapper.update(); + expect(annotateComponent.prop('paths')).toEqual( + [{ + color: 'rgba(233, 0, 0, 0.64)', + key: 'path0', + points: [{ x: 0, y: 0 }], + shouldShow: false, + strokeWidth: 5, + }]); + }); + + it('should send upload-snippet event with correct data when clicked on done', async () => { + const spy = jest.spyOn(ipcRenderer, 'send'); + const wrapper = mount(); + wrapper.find('[data-testid="done-button"]').simulate('click'); + wrapper.update(); + await waitForPromisesToResolve(); + expect(spy).toBeCalledWith('upload-snippet', { + base64PngData: 'NO CANVAS', + screenSnippetPath: 'Screen-Snippet', + }); + }); +}); diff --git a/src/app/screen-snippet-handler.ts b/src/app/screen-snippet-handler.ts index 65df8f18..24804066 100644 --- a/src/app/screen-snippet-handler.ts +++ b/src/app/screen-snippet-handler.ts @@ -230,20 +230,28 @@ class ScreenSnippet { } // remove tmp file (async) if (this.outputFileName) { - fs.unlink(this.outputFileName, (removeErr) => { - logger.info( - `screen-snippet-handler: cleaning up temp snippet file: ${this.outputFileName}!`, - ); - if (removeErr) { - logger.error( - `screen-snippet-handler: error removing temp snippet file: ${this.outputFileName}, err: ${removeErr}`, - ); - } - }); + this.deleteFile(this.outputFileName); } } } + /** + * Deletes a locally stored file + * @param filePath Path for the file to delete + */ + private deleteFile(filePath: string) { + fs.unlink(filePath, (removeErr) => { + logger.info( + `screen-snippet-handler: cleaning up temp snippet file: ${filePath}!`, + ); + if (removeErr) { + logger.error( + `screen-snippet-handler: error removing temp snippet file: ${filePath}, err: ${removeErr}`, + ); + } + }); + } + /** * Verify and updates always on top */ @@ -279,20 +287,19 @@ class ScreenSnippet { * @param webContents A browser window's web contents object */ private uploadSnippet(webContents: Electron.webContents) { - ipcMain.once('upload-snippet', async (_event, snipImage: string) => { + ipcMain.once('upload-snippet', async (_event, snippetData: { screenSnippetPath: string, base64PngData: string }) => { windowHandler.closeSnippingToolWindow(); - if (snipImage) { - this.outputFileName = snipImage; - } - const { - message, + const [type, data] = snippetData.base64PngData.split(','); + const payload = { + message: 'SUCCESS', data, type, - }: IScreenSnippet = await this.convertFileToData(); + }; + this.deleteFile(snippetData.screenSnippetPath); logger.info( `screen-snippet-handler: Snippet captured! Sending data to SFE`, ); - webContents.send('screen-snippet-data', { message, data, type }); + webContents.send('screen-snippet-data', payload); await this.verifyAndUpdateAlwaysOnTop(); }); } diff --git a/src/renderer/components/annotate-area.tsx b/src/renderer/components/annotate-area.tsx index 24334ec1..3ece20a6 100644 --- a/src/renderer/components/annotate-area.tsx +++ b/src/renderer/components/annotate-area.tsx @@ -33,12 +33,28 @@ const MIN_ANNOTATE_AREA_WIDTH = 312; const PEN_WIDTH = 5; const HIGHLIGHT_WIDTH = 28; -const AnnotateArea: React.FunctionComponent = ({ paths, highlightColor, penColor, onChange, imageDimensions, chosenTool, screenSnippetPath }) => { +const AnnotateArea: React.FunctionComponent = ({ + paths, + highlightColor, + penColor, + onChange, + imageDimensions, + chosenTool, + screenSnippetPath, +}) => { const [isDrawing, setIsDrawing] = useState(false); const maybeErasePath = (key: string) => { - // erase logic here - return key; + if (chosenTool === Tool.eraser) { + const updPaths = [...paths]; + updPaths.map((p) => { + if (p && p.key === key) { + p.shouldShow = false; + } + return p; + }); + onChange(updPaths); + } }; const stopDrawing = () => { @@ -59,10 +75,12 @@ const AnnotateArea: React.FunctionComponent = ({ paths, high // Render and preparing render functions const addHighlightPoint = (paths: IPath[], point: IPoint) => { - const activePath = paths[paths.length - 1]; - const shouldShow = true; - const key = 'path' + paths.length; - if (!isDrawing) { + if (isDrawing) { + const activePath = paths[paths.length - 1]; + activePath.points.push(point); + } else { + const shouldShow = true; + const key = 'path' + paths.length; paths.push({ points: [point], color: highlightColor, @@ -70,17 +88,17 @@ const AnnotateArea: React.FunctionComponent = ({ paths, high shouldShow, key, }); - } else { - activePath.points.push(point); } return paths; }; const addPenPoint = (paths: IPath[], point: IPoint) => { - const activePath = paths[paths.length - 1]; - const shouldShow = true; - const key = 'path' + paths.length; - if (!isDrawing) { + if (isDrawing) { + const activePath = paths[paths.length - 1]; + activePath.points.push(point); + } else { + const shouldShow = true; + const key = 'path' + paths.length; paths.push({ points: [point], color: penColor, @@ -88,21 +106,19 @@ const AnnotateArea: React.FunctionComponent = ({ paths, high shouldShow, key, }); - } else { - activePath.points.push(point); } return paths; }; const addPathPoint = (e: React.MouseEvent) => { const p = [...paths]; - const mousePos = getMousePosition(e); - lazy.update({ x: mousePos.x, y: mousePos.y }); - const point: IPoint = lazy.getBrushCoordinates(); + const mousePos: IPoint = getMousePosition(e); if (chosenTool === Tool.highlight) { + lazy.update({ x: mousePos.x, y: mousePos.y }); + const point: IPoint = lazy.getBrushCoordinates(); onChange(addHighlightPoint(p, point)); } else { - onChange(addPenPoint(p, point)); + onChange(addPenPoint(p, mousePos)); } if (!isDrawing) { setIsDrawing(true); @@ -204,7 +220,7 @@ const AnnotateArea: React.FunctionComponent = ({ paths, high void; + onChange: (color: IColor) => void; } export interface IColor { @@ -32,7 +32,7 @@ const ColorPickerPill = (props: IColorPickerPillProps) => { const widthAndHeight = getWidthAndHeight(); const chooseColor = () => { - props.onChange(color.rgbaColor); + props.onChange(color); }; return ( diff --git a/src/renderer/components/snipping-tool.tsx b/src/renderer/components/snipping-tool.tsx index aca6ebc9..98542563 100644 --- a/src/renderer/components/snipping-tool.tsx +++ b/src/renderer/components/snipping-tool.tsx @@ -38,12 +38,16 @@ export interface IImageDimensions { height: number; } +export interface ISnippingToolProps { + existingPaths?: IPath[]; +} + const availablePenColors: IColor[] = [ { rgbaColor: 'rgba(0, 0, 40, 1)' }, { rgbaColor: 'rgba(0, 142, 255, 1)' }, { rgbaColor: 'rgba(38, 196, 58, 1)' }, { rgbaColor: 'rgba(246, 178, 2, 1)' }, - { rgbaColor: 'rgba(255, 255, 255, 1)', outline: 'rgba(0, 0, 0, 1)' }, + { rgbaColor: 'rgba(255, 255, 255, 1)', outline: 'rgba(207, 208, 210, 1)' }, ]; const availableHighlightColors: IColor[] = [ { rgbaColor: 'rgba(0, 142, 255, 0.64)' }, @@ -53,7 +57,7 @@ const availableHighlightColors: IColor[] = [ ]; const SNIPPING_TOOL_NAMESPACE = 'ScreenSnippet'; -const SnippingTool = () => { +const SnippingTool: React.FunctionComponent = ({ existingPaths }) => { // State preparation functions const [screenSnippetPath, setScreenSnippetPath] = useState('Screen-Snippet'); @@ -61,11 +65,11 @@ const SnippingTool = () => { height: 600, width: 800, }); - const [paths, setPaths] = useState([]); + const [paths, setPaths] = useState(existingPaths || []); const [chosenTool, setChosenTool] = useState(Tool.pen); - const [penColor, setPenColor] = useState('rgba(0, 142, 255, 1)'); - const [highlightColor, setHighlightColor] = useState( - 'rgba(0, 142, 255, 0.64)', + const [penColor, setPenColor] = useState({ rgbaColor: 'rgba(0, 142, 255, 1)' }); + const [highlightColor, setHighlightColor] = useState( + { rgbaColor: 'rgba(0, 142, 255, 0.64)' }, ); const [ shouldRenderHighlightColorPicker, @@ -80,15 +84,9 @@ const SnippingTool = () => { setImageDimensions({ height, width }); }; - ipcRenderer.on('snipping-tool-data', getSnipImageData); + ipcRenderer.once('snipping-tool-data', getSnipImageData); - useEffect(() => { - return () => { - ipcRenderer.removeListener('snipping-tool-data', getSnipImageData); - }; - }, []); - - // Hook that alerts clicks outside of the passed ref + // Hook that alerts clicks outside of the passed refs const useClickOutsideExaminer = ( colorPickerRf: React.RefObject, penRf: React.RefObject, @@ -120,12 +118,12 @@ const SnippingTool = () => { // State mutating functions - const penColorChosen = (color: string) => { + const penColorChosen = (color: IColor) => { setPenColor(color); setShouldRenderPenColorPicker(false); }; - const highlightColorChosen = (color: string) => { + const highlightColorChosen = (color: IColor) => { setHighlightColor(color); setShouldRenderHighlightColorPicker(false); }; @@ -147,11 +145,58 @@ const SnippingTool = () => { }; const clear = () => { - // Clear logic here + const updPaths = [...paths]; + updPaths.map((p) => { + p.shouldShow = false; + return p; + }); + setPaths(updPaths); }; // Utility functions + const getBase64PngData = () => { + const canvas = document.createElement('canvas'); + canvas.width = imageDimensions.width; + canvas.height = imageDimensions.height; + + // Creates an in memory canvas for mounting img data without adding it to the DOM + const ctx = canvas?.getContext('2d') as CanvasRenderingContext2D; + + if (!ctx) { + // Will only be the case in headless browsers, such as with unit tests + return 'NO CANVAS'; + } + + // Creates an in memory img without adding it to the DOM + const img = document.createElement('img'); + + const svg = document.getElementById('annotate-area') as HTMLImageElement; + // Parses SVG image to XML data + const svgData = new XMLSerializer().serializeToString(svg); + // Adds the extracted XML data to the in memory img + img.setAttribute( + 'src', + 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgData))), + ); + const screenSnippet = document.getElementById('screenSnippet') as HTMLImageElement; + + return new Promise((resolve, reject) => { + // Listens to when the img is loaded in memory and adds the data from the SVG paths + screenSnippet to the canvas + img.onload = () => { + ctx.drawImage(screenSnippet, 0, 0); + ctx.drawImage(img, 0, 0); + try { + // Extracts base 64 png img data from the canvas + const data = canvas.toDataURL('image/png'); + resolve(data); + } catch (e) { + reject(e); + } + }; + }); + }; + const markChosenColor = (colors: IColor[], chosenColor: string) => { return colors.map((color) => { if (color.rgbaColor === chosenColor) { @@ -167,17 +212,20 @@ const SnippingTool = () => { return undefined; } if (chosenTool === Tool.pen) { - return { border: '2px solid ' + penColor }; + const color = penColor.outline ? penColor.outline : penColor.rgbaColor; + return { border: '2px solid ' + color }; } else if (chosenTool === Tool.highlight) { - return { border: '2px solid ' + highlightColor }; + const color = highlightColor.outline ? highlightColor.outline : highlightColor.rgbaColor; + return { border: '2px solid ' + color }; } else if (chosenTool === Tool.eraser) { return { border: '2px solid #008EFF' }; } return undefined; }; - const done = () => { - ipcRenderer.send('upload-snippet', screenSnippetPath); + const done = async () => { + const base64PngData = await getBase64PngData(); + ipcRenderer.send('upload-snippet', { screenSnippetPath, base64PngData }); }; return ( @@ -229,7 +277,7 @@ const SnippingTool = () => {
@@ -244,7 +292,7 @@ const SnippingTool = () => { data-testid='highlight-colorpicker' availableColors={markChosenColor( availableHighlightColors, - highlightColor, + highlightColor.rgbaColor, )} onChange={highlightColorChosen} /> @@ -256,9 +304,10 @@ const SnippingTool = () => {