SDA-3931: Snipping tools improvement

This commit is contained in:
NguyenTranHoangSym 2022-11-07 16:26:36 +07:00
parent 65505c0ccd
commit fad3656536
14 changed files with 653 additions and 20 deletions

View File

@ -0,0 +1,21 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Menu Button should show all elements 1`] = `
<Fragment>
<div
className="menu-button-wrapper"
>
<button
className="menu-button "
data-testid="undefined_MENU_BUTTON"
onClick={[Function]}
>
<img
alt="Open menu"
src="../renderer/assets/single-chevron-down.svg"
title="Open menu"
/>
</button>
</div>
</Fragment>
`;

View File

@ -86,12 +86,38 @@ exports[`Snipping Tool should render correctly 1`] = `
</main>
<footer>
<button
className="done-button"
className="close-button"
data-testid="close-button"
onClick={[Function]}
>
Close
</button>
<button
className="add-to-chat-button"
data-testid="done-button"
onClick={[Function]}
>
Done
Add to chat
</button>
<MenuButton
id="snipping-tool"
listItems={
Array [
Object {
"dataTestId": "COPY_TO_CLIPBOARD",
"event": "copy-to-clipboard",
"name": "Copy to clipboard",
"onClick": [Function],
},
Object {
"dataTestId": "SAVE_AS",
"event": "save-as",
"name": "Save as",
"onClick": [Function],
},
]
}
/>
</footer>
</div>
`;

91
spec/menu-button.spec.tsx Normal file
View File

@ -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(
<MenuButton id='snipping-tool' listItems={menuItem} />,
);
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(
<MenuButton id='snipping-tool' listItems={menuItem} />,
);
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(
<MenuButton id='snipping-tool' listItems={menuItem} />,
);
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();
});
});

View File

@ -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(<SnippingTool />);
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(<SnippingTool />);
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(<SnippingTool />);
const spy = jest.spyOn(ipcRenderer, 'send');
wrapper.find('[data-testid="close-button"]').simulate('click');
wrapper.update();
await waitForPromisesToResolve();
expect(spy).toBeCalledWith(ScreenShotAnnotation.CLOSE);
});
});

View File

@ -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<void>;
}
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();

View File

@ -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;

5
src/common/ipcEvent.ts Normal file
View File

@ -0,0 +1,5 @@
export enum ScreenShotAnnotation {
COPY_TO_CLIPBOARD = 'copy-to-clipboard',
SAVE_AS = 'save-as',
CLOSE = 'close-snippet',
}

View File

@ -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."
}
}

View File

@ -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."
}
}

View File

@ -0,0 +1,3 @@
<svg width="10" height="7" viewBox="0 0 10 7" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.292893 0.792893C0.683417 0.402369 1.31658 0.402369 1.70711 0.792893L5 4.08579L8.29289 0.792893C8.68342 0.402369 9.31658 0.402369 9.70711 0.792893C10.0976 1.18342 10.0976 1.81658 9.70711 2.20711L5.70711 6.20711C5.31658 6.59763 4.68342 6.59763 4.29289 6.20711L0.292893 2.20711C-0.0976311 1.81658 -0.0976311 1.18342 0.292893 0.792893Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 461 B

View File

@ -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<IMenuButtonProps> = ({
listItems,
id,
}) => {
//#region State
const [isDisplay, setDisplay] = useState(false);
const listRef = useRef<HTMLUListElement>(document.createElement('ul'));
const menuButtonRef = useRef<HTMLButtonElement>(
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 (
<li
data-testid={`${id}_${listItem.dataTestId}`}
className='list-item general-font'
lang={i18n.getLocale()}
onClick={sendClick}
key={listItem.event}
>
{listItem.name}
</li>
);
});
};
const focusCls = isDisplay ? 'menu-button-focus' : '';
//#endregion
return (
<>
<div className='menu-button-wrapper'>
<button
className={`menu-button ${focusCls}`}
onClick={onClickMenuButton}
data-testid={testId.menu}
ref={menuButtonRef}
>
<img
src={`../renderer/assets/single-chevron-down.svg`}
title={i18n.t('Open menu')()}
alt={i18n.t('Open menu')()}
/>
</button>
{isDisplay && (
<ul className='menu' ref={listRef}>
{renderListItems()}
</ul>
)}
</div>
</>
);
};
export default MenuButton;

View File

@ -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<ISnippingToolProps> = ({
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<ISnippingToolProps> = ({
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<ISnippingToolProps> = ({
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<ISnippingToolProps> = ({
</main>
<footer>
<button
data-testid='done-button'
className='done-button'
onClick={done}
data-testid='close-button'
className='close-button'
onClick={onClose}
>
{i18n.t('Done', SNIPPING_TOOL_NAMESPACE)()}
{i18n.t('Close', SNIPPING_TOOL_NAMESPACE)()}
</button>
<button
data-testid='done-button'
className='add-to-chat-button'
onClick={addToChat}
>
{i18n.t('Add to chat', SNIPPING_TOOL_NAMESPACE)()}
</button>
<MenuButton id='snipping-tool' listItems={menuItem}></MenuButton>
</footer>
</div>
);

View File

@ -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;
}

View File

@ -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;