diff --git a/spec/__snapshots__/menu-button.spec.tsx.snap b/spec/__snapshots__/menu-button.spec.tsx.snap new file mode 100644 index 00000000..14d45d47 --- /dev/null +++ b/spec/__snapshots__/menu-button.spec.tsx.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Menu Button should show all elements 1`] = ` + +
+ +
+
+`; diff --git a/spec/__snapshots__/snippingTool.spec.tsx.snap b/spec/__snapshots__/snippingTool.spec.tsx.snap index 824a19fe..a2d33c16 100644 --- a/spec/__snapshots__/snippingTool.spec.tsx.snap +++ b/spec/__snapshots__/snippingTool.spec.tsx.snap @@ -86,12 +86,38 @@ exports[`Snipping Tool should render correctly 1`] = ` `; diff --git a/spec/menu-button.spec.tsx b/spec/menu-button.spec.tsx new file mode 100644 index 00000000..9f1bf954 --- /dev/null +++ b/spec/menu-button.spec.tsx @@ -0,0 +1,91 @@ +import { mount, shallow } from 'enzyme'; +import * as React from 'react'; +import { ScreenShotAnnotation } from '../src/common/ipcEvent'; +import MenuButton from '../src/renderer/components/menu-button'; + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('Menu Button', () => { + //#region Logic + const waitForPromisesToResolve = () => + new Promise((resolve) => setTimeout(resolve)); + //#endregion + + //#region Mock + const copyToClipboardFn = jest.fn(); + const saveAsFn = jest.fn(); + copyToClipboardFn.mockImplementation((event) => { + return event; + }); + saveAsFn.mockImplementation((event) => { + return event; + }); + const menuItem = [ + { + name: 'Copy to clipboard', + event: ScreenShotAnnotation.COPY_TO_CLIPBOARD, + onClick: copyToClipboardFn, + dataTestId: 'COPY_TO_CLIPBOARD', + }, + { + name: 'Save as', + event: ScreenShotAnnotation.SAVE_AS, + onClick: saveAsFn, + dataTestId: 'SAVE_AS', + }, + ]; + //#endregion + + it('should show all elements', () => { + const wrapper = shallow(React.createElement(MenuButton)); + expect(wrapper).toMatchSnapshot(); + }); + + it('should call event on click copy to clipboard', async () => { + const wrapper = mount( + , + ); + + wrapper.find('[data-testid="snipping-tool_MENU_BUTTON"]').simulate('click'); + wrapper.update(); + await waitForPromisesToResolve(); + wrapper + .find('[data-testid="snipping-tool_COPY_TO_CLIPBOARD"]') + .simulate('click'); + wrapper.update(); + await waitForPromisesToResolve(); + expect(copyToClipboardFn).toBeCalledWith( + ScreenShotAnnotation.COPY_TO_CLIPBOARD, + ); + }); + + it('should call event on click save as', async () => { + const wrapper = mount( + , + ); + + wrapper.find('[data-testid="snipping-tool_MENU_BUTTON"]').simulate('click'); + wrapper.update(); + await waitForPromisesToResolve(); + wrapper.find('[data-testid="snipping-tool_SAVE_AS"]').simulate('click'); + wrapper.update(); + await waitForPromisesToResolve(); + expect(saveAsFn).toBeCalledWith(ScreenShotAnnotation.SAVE_AS); + }); + + it('should disappear on click to other element', async () => { + const wrapper = mount( + , + ); + + wrapper.find('[data-testid="snipping-tool_MENU_BUTTON"]').simulate('click'); + wrapper.update(); + await waitForPromisesToResolve(); + wrapper.find('[data-testid="snipping-tool_MENU_BUTTON"]').simulate('click'); + wrapper.update(); + await waitForPromisesToResolve(); + expect(wrapper.find('.menu').exists()).toBeFalsy(); + }); +}); diff --git a/spec/snippingTool.spec.tsx b/spec/snippingTool.spec.tsx index 32ae19b7..f0837dd3 100644 --- a/spec/snippingTool.spec.tsx +++ b/spec/snippingTool.spec.tsx @@ -1,5 +1,14 @@ +jest.mock('save-svg-as-png', function () { + return { + svgAsPngUri: async function (svg) { + return Promise.resolve(svg); + }, + }; +}); + import { mount, shallow } from 'enzyme'; import * as React from 'react'; +import { ScreenShotAnnotation } from '../src/common/ipcEvent'; import SnippingTool from '../src/renderer/components/snipping-tool'; import { ipcRenderer } from './__mocks__/electron'; @@ -112,4 +121,61 @@ describe('Snipping Tool', () => { screenSnippetPath: '', }); }); + + it('should send upload-snippet event with correct data when clicked on copy to clipboard', async () => { + const wrapper = mount(); + const spy = jest.spyOn(ipcRenderer, 'send'); + jest.spyOn(document, 'getElementById').mockImplementation((selector) => { + switch (selector) { + case 'annotate-area': + return '123'; + default: + return ''; + } + }); + + wrapper.find('[data-testid="snipping-tool_MENU_BUTTON"]').simulate('click'); + wrapper.update(); + await waitForPromisesToResolve(); + wrapper + .find('[data-testid="snipping-tool_COPY_TO_CLIPBOARD"]') + .simulate('click'); + + wrapper.update(); + await waitForPromisesToResolve(); + expect(spy).toBeCalledWith(ScreenShotAnnotation.COPY_TO_CLIPBOARD, { + clipboard: '123', + }); + }); + + it('should send upload-snippet event with correct data when clicked on save as', async () => { + const wrapper = mount(); + const spy = jest.spyOn(ipcRenderer, 'send'); + jest.spyOn(document, 'getElementById').mockImplementation((selector) => { + switch (selector) { + case 'annotate-area': + return '123'; + default: + return ''; + } + }); + wrapper.find('[data-testid="snipping-tool_MENU_BUTTON"]').simulate('click'); + wrapper.update(); + await waitForPromisesToResolve(); + wrapper.find('[data-testid="snipping-tool_SAVE_AS"]').simulate('click'); + wrapper.update(); + await waitForPromisesToResolve(); + expect(spy).toBeCalledWith(ScreenShotAnnotation.SAVE_AS, { + clipboard: '123', + }); + }); + + it('should send upload-snippet event with correct data when clicked on close', async () => { + const wrapper = mount(); + const spy = jest.spyOn(ipcRenderer, 'send'); + wrapper.find('[data-testid="close-button"]').simulate('click'); + wrapper.update(); + await waitForPromisesToResolve(); + expect(spy).toBeCalledWith(ScreenShotAnnotation.CLOSE); + }); }); diff --git a/src/app/screen-snippet-handler.ts b/src/app/screen-snippet-handler.ts index efdea051..da889f72 100644 --- a/src/app/screen-snippet-handler.ts +++ b/src/app/screen-snippet-handler.ts @@ -1,6 +1,8 @@ import { app, BrowserWindow, + clipboard, + dialog, ipcMain, nativeImage, WebContents, @@ -20,6 +22,7 @@ import { isWindowsOS, } from '../common/env'; import { i18n } from '../common/i18n'; +import { ScreenShotAnnotation } from '../common/ipcEvent'; import { logger } from '../common/logger'; import { analytics, @@ -27,11 +30,18 @@ import { ScreenSnippetActionTypes, } from './analytics-handler'; import { updateAlwaysOnTop } from './window-actions'; -import { windowHandler } from './window-handler'; +import { ICustomBrowserWindow, windowHandler } from './window-handler'; import { windowExists } from './window-utils'; const readFile = util.promisify(fs.readFile); +export interface IListItem { + name: string; + event: string; + dataTestId: string; + onClick: (eventName: string) => Promise; +} + class ScreenSnippet { private readonly tempDir: string; private outputFilePath: string | undefined; @@ -161,8 +171,18 @@ class ScreenSnippet { } windowHandler.closeSnippingToolWindow(); - windowHandler.createSnippingToolWindow(this.outputFilePath, dimensions); + const windowName = this.focusedWindow + ? (this.focusedWindow as ICustomBrowserWindow).winName + : ''; + windowHandler.createSnippingToolWindow( + this.outputFilePath, + dimensions, + windowName, + ); this.uploadSnippet(webContents); + this.closeSnippet(); + this.copyToClipboard(); + this.saveAs(); return; } const { @@ -194,7 +214,6 @@ class ScreenSnippet { this.focusedWindow = BrowserWindow.getFocusedWindow(); try { - await this.execCmd(this.captureUtil, []); await this.verifyAndUpdateAlwaysOnTop(); } catch (error) { await this.verifyAndUpdateAlwaysOnTop(); @@ -343,6 +362,124 @@ class ScreenSnippet { }, ); } + + /** + * Close the current snippet + */ + private closeSnippet() { + ipcMain.once(ScreenShotAnnotation.CLOSE, async (_event) => { + try { + windowHandler.closeSnippingToolWindow(); + await this.verifyAndUpdateAlwaysOnTop(); + } catch (error) { + await this.verifyAndUpdateAlwaysOnTop(); + logger.error( + `screen-snippet-handler: close window failed with error: ${error}!`, + ); + } + }); + } + + /** + * Cancels a screen capture and closes the snippet window + */ + private copyToClipboard() { + ipcMain.on( + ScreenShotAnnotation.COPY_TO_CLIPBOARD, + async ( + _event, + copyToClipboardData: { + action: string; + clipboard: string; + }, + ) => { + logger.info(`screen-snippet-handler: Copied!`); + this.focusedWindow = BrowserWindow.getFocusedWindow(); + + try { + const [, data] = copyToClipboardData.clipboard.split(','); + + const buffer = Buffer.from(data, 'base64'); + const img = nativeImage.createFromBuffer(buffer); + + clipboard.writeImage(img); + + await this.verifyAndUpdateAlwaysOnTop(); + } catch (error) { + await this.verifyAndUpdateAlwaysOnTop(); + logger.error( + `screen-snippet-handler: cannot copy, failed with error: ${error}!`, + ); + } + }, + ); + } + + /** + * Trigger save modal to save the snippet + */ + private saveAs() { + ipcMain.on( + ScreenShotAnnotation.SAVE_AS, + async ( + _event, + saveAsData: { + clipboard: string; + }, + ) => { + const filePath = path.join( + this.tempDir, + 'symphonyImage-' + Date.now() + '.png', + ); + const [, data] = saveAsData.clipboard.split(','); + const buffer = Buffer.from(data, 'base64'); + const img = nativeImage.createFromBuffer(buffer); + + const dialogResult = await dialog + .showSaveDialog(BrowserWindow.getFocusedWindow() as BrowserWindow, { + title: 'Select place to store your file', + defaultPath: filePath, + // defaultPath: path.join(__dirname, '../assets/'), + buttonLabel: 'Save', + // Restricting the user to only Text Files. + filters: [ + { + name: 'Image file', + extensions: ['png'], + }, + ], + properties: [], + }) + .then((file) => { + // Stating whether dialog operation was cancelled or not. + if (!file.canceled && file.filePath) { + // Creating and Writing to the sample.txt file + fs.writeFile(file.filePath.toString(), img.toPNG(), (err) => { + if (err) { + throw logger.error( + `screen-snippet-handler: cannot save file, failed with error: ${err}!`, + ); + } + + logger.info(`screen-snippet-handler: modal save opened!`); + }); + } + + return file; + }) + .catch((err) => { + logger.error( + `screen-snippet-handler: cannot save file, failed with error: ${err}!`, + ); + + return undefined; + }); + if (dialogResult?.filePath) { + windowHandler.closeSnippingToolWindow(); + } + }, + ); + } } const screenSnippet = new ScreenSnippet(); diff --git a/src/app/window-handler.ts b/src/app/window-handler.ts index 8ed8f7eb..0616aaac 100644 --- a/src/app/window-handler.ts +++ b/src/app/window-handler.ts @@ -20,6 +20,7 @@ import { format, parse } from 'url'; import { apiName, Themes, WindowTypes } from '../common/api-interface'; import { isDevEnv, isLinux, isMac, isWindowsOS } from '../common/env'; import { i18n, LocaleType } from '../common/i18n'; +import { ScreenShotAnnotation } from '../common/ipcEvent'; import { logger } from '../common/logger'; import { calculatePercentage, @@ -1101,6 +1102,7 @@ export class WindowHandler { height: number; width: number; }, + windowName: string, ): void { // Prevents creating multiple instances if (didVerifyAndRestoreWindow(this.snippingToolWindow)) { @@ -1184,11 +1186,13 @@ export class WindowHandler { toolWidth = scaledImageDimensions.width; } + const selectedParentWindow = getWindowByName(windowName); const opts: ICustomBrowserWindowConstructorOpts = this.getWindowOpts( { width: toolWidth, height: toolHeight, - modal: false, + parent: selectedParentWindow, + modal: true, alwaysOnTop: false, resizable: false, fullscreenable: false, @@ -1270,6 +1274,9 @@ export class WindowHandler { logger.info( 'window-handler, createSnippingToolWindow: Closing snipping window, attempting to delete temp snip image', ); + ipcMain.removeAllListeners(ScreenShotAnnotation.COPY_TO_CLIPBOARD); + ipcMain.removeAllListeners(ScreenShotAnnotation.SAVE_AS); + this.snippingToolWindow?.close(); this.deleteFile(snipImage); this.removeWindow(opts.winKey); this.screenPickerWindow = null; diff --git a/src/common/ipcEvent.ts b/src/common/ipcEvent.ts new file mode 100644 index 00000000..a34bb6cb --- /dev/null +++ b/src/common/ipcEvent.ts @@ -0,0 +1,5 @@ +export enum ScreenShotAnnotation { + COPY_TO_CLIPBOARD = 'copy-to-clipboard', + SAVE_AS = 'save-as', + CLOSE = 'close-snippet', +} diff --git a/src/locale/en-US.json b/src/locale/en-US.json index 59ded3b1..e2081545 100644 --- a/src/locale/en-US.json +++ b/src/locale/en-US.json @@ -160,7 +160,11 @@ "You are sharing your screen on {appName}": "You are sharing your screen on {appName}" }, "ScreenSnippet": { - "Done": "Done", + "Add to chat": "ADD TO CHAT", + "Open menu": "Open menu", + "Copy to clipboard": "Copy to clipboard", + "Save as": "Save as", + "Close": "Close", "Erase": "Erase", "Highlight": "Highlight", "Pen": "Pen", @@ -236,4 +240,4 @@ "Allow once (risky)": "Allow once (risky)", "Deny": "Deny", "Invalid security certificate": "has an invalid security certificate." -} \ No newline at end of file +} diff --git a/src/locale/en.json b/src/locale/en.json index 59ded3b1..e943b238 100644 --- a/src/locale/en.json +++ b/src/locale/en.json @@ -160,7 +160,11 @@ "You are sharing your screen on {appName}": "You are sharing your screen on {appName}" }, "ScreenSnippet": { - "Done": "Done", + "Add to chat": "Add to chat", + "Open menu": "Open menu", + "Copy to clipboard": "Copy to clipboard", + "Save as": "Save as", + "Close": "Close", "Erase": "Erase", "Highlight": "Highlight", "Pen": "Pen", @@ -236,4 +240,4 @@ "Allow once (risky)": "Allow once (risky)", "Deny": "Deny", "Invalid security certificate": "has an invalid security certificate." -} \ No newline at end of file +} diff --git a/src/renderer/assets/single-chevron-down.svg b/src/renderer/assets/single-chevron-down.svg new file mode 100644 index 00000000..f0c9dda0 --- /dev/null +++ b/src/renderer/assets/single-chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/renderer/components/menu-button.tsx b/src/renderer/components/menu-button.tsx new file mode 100644 index 00000000..eb8f20a8 --- /dev/null +++ b/src/renderer/components/menu-button.tsx @@ -0,0 +1,120 @@ +// import { ipcRenderer } from 'electron'; +import * as React from 'react'; +import { IListItem } from '../../app/screen-snippet-handler'; +import { i18n } from '../../common/i18n-preload'; + +const { useState, useEffect, useRef } = React; + +interface IMenuButtonProps { + listItems: IListItem[]; + id; +} + +const MenuButton: React.FunctionComponent = ({ + listItems, + id, +}) => { + //#region State + const [isDisplay, setDisplay] = useState(false); + const listRef = useRef(document.createElement('ul')); + const menuButtonRef = useRef( + document.createElement('button'), + ); + //#endregion + + //#region Variables + const testId = { + menu: `${id}_MENU_BUTTON`, + }; + //#endregion + + //#region Handlers + const onClickMenuButton = () => { + setDisplay(!isDisplay); + }; + //#endregion + + //#region UseEffect + useEffect(() => { + const isContainListElement = (ev) => + listRef.current && listRef.current.contains(ev.target as HTMLElement); + const isContainMenuBtnElement = (ev) => + menuButtonRef.current && + menuButtonRef.current.contains(ev.target as HTMLElement); + window.addEventListener('click', (ev: MouseEvent) => { + if (!isContainListElement(ev) && !isContainMenuBtnElement(ev)) { + setDisplay(false); + } + }); + window.addEventListener('keydown', (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setDisplay(false); + } + }); + + return () => { + window.removeEventListener('click', () => { + if (!isContainListElement || !isContainMenuBtnElement) { + setDisplay(false); + } + }); + window.addEventListener('keydown', (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setDisplay(false); + } + }); + }; + }); + //#endregion + + const renderListItems = () => { + return listItems.map((listItem) => { + const sendClick = async () => { + await listItem.onClick(listItem.event); + setDisplay(false); + }; + + return ( +
  • + {listItem.name} +
  • + ); + }); + }; + + const focusCls = isDisplay ? 'menu-button-focus' : ''; + + //#endregion + + return ( + <> +
    + + {isDisplay && ( +
      + {renderListItems()} +
    + )} +
    + + ); +}; + +export default MenuButton; diff --git a/src/renderer/components/snipping-tool.tsx b/src/renderer/components/snipping-tool.tsx index 147c0eef..6a7c5cf3 100644 --- a/src/renderer/components/snipping-tool.tsx +++ b/src/renderer/components/snipping-tool.tsx @@ -2,12 +2,14 @@ import { ipcRenderer } from 'electron'; import * as React from 'react'; import { svgAsPngUri } from 'save-svg-as-png'; import { i18n } from '../../common/i18n-preload'; +import { ScreenShotAnnotation } from '../../common/ipcEvent'; import { AnalyticsElements, ScreenSnippetActionTypes, } from './../../app/analytics-handler'; import AnnotateArea from './annotate-area'; import ColorPickerPill, { IColor } from './color-picker-pill'; +import MenuButton from './menu-button'; const { useState, useRef, useEffect, useLayoutEffect } = React; @@ -92,6 +94,42 @@ const SnippingTool: React.FunctionComponent = ({ false, ); + const mergeImage = async () => { + const svg = document.getElementById('annotate-area'); + const mergedImageData = svg ? await svgAsPngUri(svg, {}) : 'MERGE_FAIL'; + + return mergedImageData; + }; + + const onCopyToClipboard = async (eventName) => { + const img = await mergeImage(); + ipcRenderer.send(eventName, { + clipboard: img, + }); + }; + + const onSaveAs = async (eventName) => { + const img = await mergeImage(); + ipcRenderer.send(eventName, { + clipboard: img, + }); + }; + + const menuItem = [ + { + name: i18n.t('Copy to clipboard', SNIPPING_TOOL_NAMESPACE)(), + event: ScreenShotAnnotation.COPY_TO_CLIPBOARD, + onClick: onCopyToClipboard, + dataTestId: 'COPY_TO_CLIPBOARD', + }, + { + name: i18n.t('Save as', SNIPPING_TOOL_NAMESPACE)(), + event: ScreenShotAnnotation.SAVE_AS, + onClick: onSaveAs, + dataTestId: 'SAVE_AS', + }, + ]; + const getSnipImageData = ( {}, { @@ -225,7 +263,7 @@ const SnippingTool: React.FunctionComponent = ({ return undefined; }; - const done = async () => { + const addToChat = async () => { const svg = document.getElementById('annotate-area'); const mergedImageData = svg ? await svgAsPngUri(document.getElementById('annotate-area'), {}) @@ -237,6 +275,10 @@ const SnippingTool: React.FunctionComponent = ({ ipcRenderer.send('upload-snippet', { screenSnippetPath, mergedImageData }); }; + const onClose = async () => { + ipcRenderer.send(ScreenShotAnnotation.CLOSE); + }; + // Removes focus styling from buttons when mouse is clicked document.body.addEventListener('mousedown', () => { document.body.classList.add('using-mouse'); @@ -350,12 +392,20 @@ const SnippingTool: React.FunctionComponent = ({ ); diff --git a/src/renderer/styles/snipping-tool.less b/src/renderer/styles/snipping-tool.less index e6b836be..282c54fa 100644 --- a/src/renderer/styles/snipping-tool.less +++ b/src/renderer/styles/snipping-tool.less @@ -124,18 +124,42 @@ body.using-mouse :focus { padding-top: 16px; padding-bottom: 24px; - .done-button { + .add-to-chat-button { border: none; border-radius: 16px; font-size: 0.75rem; font-weight: 600; - background-color: #008eff; - color: white; + background-color: @electricity-ui-50; + color: @vanilla-white; cursor: pointer; text-transform: uppercase; - margin-right: 32px; + border-radius: 16px 0px 0px 16px; height: 32px; - width: 80px; + width: 102px; + } + + .add-to-chat-button:hover { + background-color: @electricity-ui-60; + } + + .close-button { + display: flex; + flex-direction: row; + align-items: center; + font-style: normal; + font-weight: 600; + font-size: 0.875rem; + padding: 8px 16px; + width: 67px; + height: 32px; + border-radius: 16px; + font-family: @font-family; + color: @graphite-50; + text-transform: uppercase; + margin-right: 16px; + border: none; + background-color: transparent; + cursor: pointer; } } } @@ -172,3 +196,73 @@ body.using-mouse :focus { width: 8px; height: 8px; } + +.menu-button { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + padding: 8px 8px 8px 6px; + width: 30px; + height: 32px; + background: @electricity-ui-50; + border-radius: 0px 16px 16px 0px; + margin-left: 1px; + border: none; + cursor: pointer; +} + +.menu-button:focus { + background: @electricity-ui-60; +} + +.menu-button-focus { + background: @electricity-ui-60; +} + +.chevron-down { + color: @text-color-primary; +} + +.menu { + box-sizing: border-box; + top: -85px; + left: -113px; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: space-between; + padding: 14px 16px; + position: absolute; + width: 143px; + min-height: 80px; + background: @vanilla-white; + box-shadow: 0px 2px 4px rgba(5, 6, 6, 0.08), 0px 12px 28px rgba(5, 6, 6, 0.16); + border-radius: 8px; + margin: 0px; + cursor: pointer; +} + +.menu-button-wrapper { + position: relative; + margin-right: 32px; +} + +.general-font { + font-weight: 400; + font-size: 0.875rem; + line-height: 20px; + color: @graphite-80; + font-family: @font-family; +} + +.list-item { + display: flex; + align-items: center; + list-style-type: none; + width: 100%; +} + +.general-font:lang(ja-JP) { + font-family: @font-family-ja; +} diff --git a/src/renderer/styles/theme.less b/src/renderer/styles/theme.less index 835639a3..46ccf9a8 100644 --- a/src/renderer/styles/theme.less +++ b/src/renderer/styles/theme.less @@ -9,9 +9,14 @@ @electricity-ui-05: #e9f2f9; @electricity-ui-50: #0277d6; @electricity-ui-30: #6eb9fd; +@electricity-ui-60: #27588e; + @graphite-20: #cdcfd4; @graphite-05: #f1f1f3; @graphite-80: #27292c; @graphite-30: #b0b3ba; @graphite-40: #8f959e; @graphite-90: #141618; +@graphite-50: #717681; + +@vanilla-white: #fff;