(channel: Channels, ...args: T[]): Promise {
21 | return ipcRenderer.invoke(channel, ...args);
22 | },
23 | removeListener(channel: Channels, listener: (...args: T[]) => void) {
24 | ipcRenderer.removeListener(channel, listener);
25 | },
26 | removeAllListeners(channel: Channels) {
27 | ipcRenderer.removeAllListeners(channel);
28 | },
29 | },
30 | store: {
31 | get(val: any) {
32 | return ipcRenderer.sendSync('electron-store-get', val);
33 | },
34 | set(property: string, val: any) {
35 | ipcRenderer.send('electron-store-set', property, val);
36 | },
37 | // Other method you want to add like has(), reset(), etc.
38 | },
39 | });
40 |
41 | window.onerror = function (errorMsg, url, lineNumber) {
42 | // eslint-disable-next-line no-console
43 | console.log(`Unhandled error: ${errorMsg} ${url} ${lineNumber}`);
44 | // Code to run when an error has occurred on the page
45 | };
46 |
47 | window.addEventListener('DOMContentLoaded', () => {
48 | const customTitlebarStatus = ipcRenderer.sendSync(
49 | 'electron-store-get',
50 | 'userPreferences.customTitlebar'
51 | ) as boolean;
52 |
53 | if (customTitlebarStatus && process.platform === 'win32') {
54 | // eslint-disable-next-line no-new
55 | new Titlebar({
56 | backgroundColor: TitlebarColor.fromHex('#0f172a'), // slate-900
57 | itemBackgroundColor: TitlebarColor.fromHex('#1e293b'), // slate-800
58 | });
59 | }
60 | });
61 |
--------------------------------------------------------------------------------
/desktop-app/src/main/protocol-handler/index.ts:
--------------------------------------------------------------------------------
1 | import { BrowserWindow } from 'electron';
2 | import { IPC_MAIN_CHANNELS } from '../../common/constants';
3 |
4 | // eslint-disable-next-line import/prefer-default-export
5 | export const openUrl = (url: string, mainWindow: BrowserWindow | null) => {
6 | mainWindow?.webContents.send(IPC_MAIN_CHANNELS.OPEN_URL, {
7 | url,
8 | });
9 | };
10 |
--------------------------------------------------------------------------------
/desktop-app/src/main/screenshot/index.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable promise/always-return */
2 | import { Device } from 'common/deviceList';
3 | import { ipcMain, shell, webContents } from 'electron';
4 | import { writeFile, ensureDir } from 'fs-extra';
5 | import path from 'path';
6 | import store from '../../store';
7 |
8 | export interface ScreenshotArgs {
9 | webContentsId: number;
10 | fullPage?: boolean;
11 | device: Device;
12 | }
13 |
14 | export interface ScreenshotAllArgs {
15 | webContentsId: number;
16 | device: Device;
17 | previousHeight: string;
18 | previousTransform: string;
19 | pageHeight: number;
20 | }
21 |
22 | export interface ScreenshotResult {
23 | done: boolean;
24 | }
25 | const captureImage = async (
26 | webContentsId: number
27 | ): Promise => {
28 | const WebContents = webContents.fromId(webContentsId);
29 |
30 | const isExecuted = await WebContents?.executeJavaScript(`
31 | if (window.isExecuted) {
32 | true;
33 | }
34 | `);
35 |
36 | if (!isExecuted) {
37 | await WebContents?.executeJavaScript(`
38 | const bgColor = window.getComputedStyle(document.body).backgroundColor;
39 | if (bgColor === 'rgba(0, 0, 0, 0)') {
40 | document.body.style.backgroundColor = 'white';
41 | }
42 | window.isExecuted = true;
43 | `);
44 | }
45 |
46 | const Image = await WebContents?.capturePage();
47 | return Image;
48 | };
49 |
50 | const quickScreenshot = async (
51 | arg: ScreenshotArgs
52 | ): Promise => {
53 | const {
54 | webContentsId,
55 | device: { name },
56 | } = arg;
57 | const image = await captureImage(webContentsId);
58 | if (image === undefined) {
59 | return { done: false };
60 | }
61 | const fileName = name.replaceAll('/', '-').replaceAll(':', '-');
62 | const dir = store.get('userPreferences.screenshot.saveLocation');
63 | const filePath = path.join(dir, `/${fileName}-${Date.now()}.jpeg`);
64 | await ensureDir(dir);
65 | await writeFile(filePath, image.toJPEG(100));
66 | setTimeout(() => shell.showItemInFolder(filePath), 100);
67 |
68 | return { done: true };
69 | };
70 |
71 | const captureAllDecies = async (
72 | args: Array
73 | ): Promise => {
74 | const screenShots = args.map((arg) => {
75 | const { device, webContentsId } = arg;
76 | const screenShotArg: ScreenshotArgs = { device, webContentsId };
77 | return quickScreenshot(screenShotArg);
78 | });
79 |
80 | await Promise.all(screenShots);
81 | return { done: true };
82 | };
83 |
84 | export const initScreenshotHandlers = () => {
85 | ipcMain.handle(
86 | 'screenshot',
87 | async (_, arg: ScreenshotArgs): Promise => {
88 | return quickScreenshot(arg);
89 | }
90 | );
91 |
92 | ipcMain.handle(
93 | 'screenshot:All',
94 | async (event, args: Array) => {
95 | return captureAllDecies(args);
96 | }
97 | );
98 | };
99 |
--------------------------------------------------------------------------------
/desktop-app/src/main/screenshot/webpage.ts:
--------------------------------------------------------------------------------
1 | class WebPage {
2 | webview: Electron.WebContents;
3 |
4 | constructor(webview: Electron.WebContents) {
5 | this.webview = webview;
6 | }
7 |
8 | async getPageHeight() {
9 | return this.webview.executeJavaScript('document.body.scrollHeight');
10 | }
11 |
12 | async getViewportHeight() {
13 | return this.webview.executeJavaScript('window.innerHeight');
14 | }
15 |
16 | async scrollTo(x: number, y: number) {
17 | return this.webview.executeJavaScript(`window.scrollTo(${x}, ${y})`);
18 | }
19 | }
20 |
21 | export default WebPage;
22 |
--------------------------------------------------------------------------------
/desktop-app/src/main/util.ts:
--------------------------------------------------------------------------------
1 | /* eslint import/prefer-default-export: off */
2 | import { URL } from 'url';
3 | import path from 'path';
4 | import { app } from 'electron';
5 | import fs from 'fs-extra';
6 | import os from 'os';
7 |
8 | export function resolveHtmlPath(htmlFileName: string) {
9 | if (process.env.NODE_ENV === 'development') {
10 | const port = process.env.PORT || 1212;
11 | const url = new URL(`http://localhost:${port}`);
12 | url.pathname = htmlFileName;
13 | return url.href;
14 | }
15 | return `file://${path.resolve(__dirname, '../renderer/', htmlFileName)}`;
16 | }
17 |
18 | let isCliArgResult: boolean | undefined;
19 |
20 | export function isValidCliArgURL(arg?: string): boolean {
21 | if (isCliArgResult !== undefined) {
22 | return isCliArgResult;
23 | }
24 | if (arg == null || arg === '') {
25 | isCliArgResult = false;
26 | return false;
27 | }
28 | try {
29 | const url = new URL(arg);
30 | if (
31 | url.protocol === 'http:' ||
32 | url.protocol === 'https:' ||
33 | url.protocol === 'file:'
34 | ) {
35 | isCliArgResult = true;
36 | return true;
37 | }
38 | // eslint-disable-next-line no-console
39 | console.warn('Protocol not supported', url.protocol);
40 | } catch (e) {
41 | // eslint-disable-next-line no-console
42 | console.warn('Not a valid URL', arg, e);
43 | }
44 | isCliArgResult = false;
45 | return false;
46 | }
47 |
48 | export const getPackageJson = () => {
49 | let appPath;
50 | if (process.env.NODE_ENV === 'production') appPath = app.getAppPath();
51 | else appPath = process.cwd();
52 |
53 | const pkgPath = path.join(appPath, 'package.json');
54 | if (fs.existsSync(pkgPath)) {
55 | const pkgContent = fs.readFileSync(pkgPath, 'utf-8');
56 | return JSON.parse(pkgContent);
57 | }
58 | console.error(`cant find package.json in: '${appPath}'`);
59 | return {};
60 | };
61 |
62 | export interface EnvironmentInfo {
63 | appVersion: string;
64 | electronVersion: string;
65 | chromeVersion: string;
66 | nodeVersion: string;
67 | v8Version: string;
68 | osInfo: string;
69 | }
70 |
71 | export const getEnvironmentInfo = (): EnvironmentInfo => {
72 | const pkg = getPackageJson();
73 | const appVersion = pkg.version || 'Unknown';
74 | const electronVersion = process.versions.electron || 'Unknown';
75 | const chromeVersion = process.versions.chrome || 'Unknown';
76 | const nodeVersion = process.versions.node || 'Unknown';
77 | const v8Version = process.versions.v8 || 'Unknown';
78 | const osInfo =
79 | `${os.type()} ${os.arch()} ${os.release()}`.trim() || 'Unknown';
80 |
81 | return {
82 | appVersion,
83 | electronVersion,
84 | chromeVersion,
85 | nodeVersion,
86 | v8Version,
87 | osInfo,
88 | };
89 | };
90 |
--------------------------------------------------------------------------------
/desktop-app/src/main/web-permissions/index.ts:
--------------------------------------------------------------------------------
1 | import { BrowserWindow, session } from 'electron';
2 | import PermissionsManager, { PERMISSION_STATE } from './PermissionsManager';
3 | import store from '../../store';
4 |
5 | // eslint-disable-next-line import/prefer-default-export
6 | export const WebPermissionHandlers = (mainWindow: BrowserWindow) => {
7 | const permissionsManager = new PermissionsManager(mainWindow);
8 | return {
9 | init: () => {
10 | session.defaultSession.setPermissionRequestHandler(
11 | (webContents, permission, callback) => {
12 | permissionsManager.requestPermission(
13 | new URL(webContents.getURL()).origin,
14 | permission,
15 | callback
16 | );
17 | }
18 | );
19 |
20 | session.defaultSession.setPermissionCheckHandler(
21 | (_webContents, permission, requestingOrigin) => {
22 | const status = permissionsManager.getPermissionState(
23 | requestingOrigin,
24 | permission
25 | );
26 | return status === PERMISSION_STATE.GRANTED;
27 | }
28 | );
29 |
30 | session.defaultSession.webRequest.onBeforeSendHeaders(
31 | {
32 | urls: [''],
33 | },
34 | (details, callback) => {
35 | const acceptLanguage = store.get(
36 | 'userPreferences.webRequestHeaderAcceptLanguage'
37 | );
38 | if (acceptLanguage != null && acceptLanguage !== '') {
39 | details.requestHeaders['Accept-Language'] = store.get(
40 | 'userPreferences.webRequestHeaderAcceptLanguage'
41 | );
42 | }
43 | callback({ requestHeaders: details.requestHeaders });
44 | }
45 | );
46 | },
47 | };
48 | };
49 |
--------------------------------------------------------------------------------
/desktop-app/src/main/webview-context-menu/common.ts:
--------------------------------------------------------------------------------
1 | interface ContextMenuMetadata {
2 | id: string;
3 | label: string;
4 | }
5 |
6 | export const CONTEXT_MENUS: { [key: string]: ContextMenuMetadata } = {
7 | INSPECT_ELEMENT: { id: 'INSPECT_ELEMENT', label: 'Inspect Element' },
8 | OPEN_CONSOLE: { id: 'OPEN_CONSOLE', label: 'Open Console' },
9 | };
10 |
--------------------------------------------------------------------------------
/desktop-app/src/main/webview-context-menu/register.ts:
--------------------------------------------------------------------------------
1 | import { BrowserWindow, ipcMain, Menu } from 'electron';
2 | import { CONTEXT_MENUS } from './common';
3 | // import { webViewPubSub } from '../../renderer/lib/pubsub';
4 | // import { MOUSE_EVENTS } from '../ruler';
5 |
6 | export const initWebviewContextMenu = () => {
7 | ipcMain.removeAllListeners('show-context-menu');
8 | ipcMain.on('show-context-menu', (event, ...args) => {
9 | const template: Electron.MenuItemConstructorOptions[] = Object.values(
10 | CONTEXT_MENUS
11 | ).map((menu) => {
12 | return {
13 | label: menu.label,
14 | click: () => {
15 | event.sender.send('context-menu-command', {
16 | command: menu.id,
17 | arg: args[0],
18 | });
19 | },
20 | };
21 | });
22 | const menu = Menu.buildFromTemplate(template);
23 | menu.popup(
24 | BrowserWindow.fromWebContents(event.sender) as Electron.PopupOptions
25 | );
26 | });
27 | // ipcMain.on('pass-scroll-data', (event, ...args) => {
28 | // console.log(args[0].coordinates);
29 | // webViewPubSub.publish(MOUSE_EVENTS.SCROLL, [args[0].coordinates]);
30 | // });
31 | };
32 |
33 | export default initWebviewContextMenu;
34 |
--------------------------------------------------------------------------------
/desktop-app/src/main/webview-storage-manager/index.ts:
--------------------------------------------------------------------------------
1 | import { ClearStorageDataOptions, ipcMain, webContents } from 'electron';
2 |
3 | export interface DeleteStorageArgs {
4 | webContentsId: number;
5 | storages?: string[];
6 | }
7 |
8 | export interface DeleteStorageResult {
9 | done: boolean;
10 | }
11 |
12 | const deleteStorage = async (
13 | arg: DeleteStorageArgs
14 | ): Promise => {
15 | const { webContentsId, storages } = arg;
16 | if (storages?.length === 1 && storages[0] === 'network-cache') {
17 | await webContents.fromId(webContentsId)?.session.clearCache();
18 | } else {
19 | await webContents
20 | .fromId(webContentsId)
21 | ?.session.clearStorageData({ storages } as ClearStorageDataOptions);
22 | }
23 | return { done: true };
24 | };
25 |
26 | export const initWebviewStorageManagerHandlers = () => {
27 | ipcMain.handle(
28 | 'delete-storage',
29 | async (_, arg: DeleteStorageArgs): Promise => {
30 | return deleteStorage(arg);
31 | }
32 | );
33 | };
34 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/App.css:
--------------------------------------------------------------------------------
1 | /*
2 | * @NOTE: Prepend a `~` to css file paths that are in your node_modules
3 | * See https://github.com/webpack-contrib/sass-loader#imports
4 | */
5 |
6 | @import '~@fontsource/lato/300.css';
7 | @import '~@fontsource/lato/400.css';
8 | @import '~@fontsource/lato/400-italic.css';
9 | @import '~@fontsource/lato/700.css';
10 |
11 | @tailwind base;
12 | @tailwind components;
13 | @tailwind utilities;
14 |
15 | html {
16 | user-select: none;
17 | }
18 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/AppContent.tsx:
--------------------------------------------------------------------------------
1 | import { Provider, useSelector } from 'react-redux';
2 |
3 | import ToolBar from './components/ToolBar';
4 | import Previewer from './components/Previewer';
5 | import { store } from './store';
6 |
7 | import './App.css';
8 | import ThemeProvider from './context/ThemeProvider';
9 | import type { AppView } from './store/features/ui';
10 | import { APP_VIEWS, selectAppView } from './store/features/ui';
11 | import DeviceManager from './components/DeviceManager';
12 | import KeyboardShortcutsManager from './components/KeyboardShortcutsManager';
13 | import { ReleaseNotes } from './components/ReleaseNotes';
14 | import { Sponsorship } from './components/Sponsorship';
15 | import { AboutDialog } from './components/AboutDialog';
16 |
17 | if ((navigator as any).userAgentData.platform === 'Windows') {
18 | import('./titlebar-styles.css');
19 | }
20 |
21 | const Browser = () => {
22 | return (
23 |
27 | );
28 | };
29 |
30 | const getView = (appView: AppView) => {
31 | switch (appView) {
32 | case APP_VIEWS.BROWSER:
33 | return ;
34 | case APP_VIEWS.DEVICE_MANAGER:
35 | return ;
36 | default:
37 | return ;
38 | }
39 | };
40 |
41 | const ViewComponent = () => {
42 | const appView = useSelector(selectAppView);
43 |
44 | return <>{getView(appView)}>;
45 | };
46 |
47 | const AppContent = () => {
48 | return (
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 | );
59 | };
60 | export default AppContent;
61 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/assets/img/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/responsively-org/responsively-app/5323ae73f815f44a555f47f1631323df6e186360/desktop-app/src/renderer/assets/img/logo.png
--------------------------------------------------------------------------------
/desktop-app/src/renderer/assets/sfx/screenshot.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/responsively-org/responsively-app/5323ae73f815f44a555f47f1631323df6e186360/desktop-app/src/renderer/assets/sfx/screenshot.mp3
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/Accordion/Accordion.tsx:
--------------------------------------------------------------------------------
1 | export const Accordion = ({ children }: { children: JSX.Element }) => {
2 | return (
3 |
4 | {children}
5 |
6 | );
7 | };
8 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/Accordion/AccordionItem.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 |
3 | type AccordionItemProps = {
4 | title: string;
5 | children: JSX.Element;
6 | };
7 |
8 | export const AccordionItem = ({ title, children }: AccordionItemProps) => {
9 | const [isOpen, setIsOpen] = useState(true);
10 |
11 | const toggle = () => {
12 | setIsOpen(!isOpen);
13 | };
14 |
15 | return (
16 |
17 |
18 |
25 | {title}
26 |
33 |
40 |
41 |
42 |
43 |
48 |
49 | {children}
50 |
51 |
52 |
53 | );
54 | };
55 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/Accordion/index.tsx:
--------------------------------------------------------------------------------
1 | export { Accordion } from './Accordion';
2 | export { AccordionItem } from './AccordionItem';
3 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/Button/Button.test.tsx:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom';
2 | import { act, render, screen } from '@testing-library/react';
3 | import Button from './index';
4 |
5 | jest.mock('@iconify/react', () => ({
6 | Icon: () =>
,
7 | }));
8 |
9 | describe('Button Component', () => {
10 | it('renders with default props', () => {
11 | render(Click me );
12 | const buttonElement = screen.getByRole('button', { name: /click me/i });
13 | expect(buttonElement).toBeInTheDocument();
14 | });
15 |
16 | it('applies custom class name', () => {
17 | render(Click me );
18 | const buttonElement = screen.getByRole('button', { name: /click me/i });
19 | expect(buttonElement).toHaveClass('custom-class');
20 | });
21 |
22 | it('renders loading icon when isLoading is true', () => {
23 | render(Click me );
24 | const loadingIcon = screen.getByTestId('icon');
25 | expect(loadingIcon).toBeInTheDocument();
26 | });
27 |
28 | it('renders confirmation icon when loading is done', () => {
29 | jest.useFakeTimers();
30 | const { rerender } = render(Click me );
31 |
32 | act(() => {
33 | rerender(Click me );
34 | jest.runAllTimers(); // Use act to advance timers
35 | });
36 |
37 | const confirmationIcon = screen.getByTestId('icon');
38 | expect(confirmationIcon).toBeInTheDocument();
39 | jest.useRealTimers();
40 | });
41 |
42 | it('applies primary button styles', () => {
43 | render(Click me );
44 | const buttonElement = screen.getByRole('button', { name: /click me/i });
45 | expect(buttonElement).toHaveClass('bg-emerald-500');
46 | expect(buttonElement).toHaveClass('text-white');
47 | });
48 |
49 | it('applies action button styles', () => {
50 | render(Click me );
51 | const buttonElement = screen.getByRole('button', { name: /click me/i });
52 | expect(buttonElement).toHaveClass('bg-slate-200');
53 | });
54 |
55 | it('applies subtle hover styles', () => {
56 | render(Click me );
57 | const buttonElement = screen.getByRole('button', { name: /click me/i });
58 | expect(buttonElement).toHaveClass('hover:bg-slate-200');
59 | });
60 |
61 | it('disables hover effects when disableHoverEffects is true', () => {
62 | render(
63 |
64 | Click me
65 |
66 | );
67 | const buttonElement = screen.getByRole('button', { name: /click me/i });
68 | expect(buttonElement).not.toHaveClass('hover:bg-slate-200');
69 | });
70 |
71 | it('renders children correctly when not loading or loading done', () => {
72 | render(Click me );
73 | const buttonElement = screen.getByText('Click me');
74 | expect(buttonElement).toBeInTheDocument();
75 | });
76 |
77 | it('does not render children when loading or loading done', () => {
78 | const { rerender } = render(Click me );
79 | expect(screen.queryByText('Click me')).not.toBeInTheDocument();
80 |
81 | act(() => {
82 | rerender(Click me );
83 | });
84 |
85 | expect(screen.queryByText('Click me')).not.toBeInTheDocument();
86 | });
87 | });
88 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/Button/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef, useState } from 'react';
2 | import cx from 'classnames';
3 | import { Icon } from '@iconify/react';
4 |
5 | interface CustomProps {
6 | className?: string;
7 | isActive?: boolean;
8 | isLoading?: boolean;
9 | isPrimary?: boolean;
10 | isTextButton?: boolean;
11 | disableHoverEffects?: boolean;
12 | isActionButton?: boolean;
13 | subtle?: boolean;
14 | }
15 |
16 | const Button = ({
17 | className = '',
18 | isActive = false,
19 | isLoading = false,
20 | isPrimary = false,
21 | isTextButton = false,
22 | isActionButton = false,
23 | subtle = false,
24 | disableHoverEffects = false,
25 | children,
26 | ...props
27 | }: CustomProps &
28 | React.DetailedHTMLProps<
29 | React.ButtonHTMLAttributes,
30 | HTMLButtonElement
31 | >) => {
32 | const [isLoadingDone, setIsLoadingDone] = useState(false);
33 | const prevLoadingState = useRef(false);
34 |
35 | useEffect(() => {
36 | if (!isLoading && prevLoadingState.current === true) {
37 | setIsLoadingDone(true);
38 | setTimeout(() => {
39 | setIsLoadingDone(false);
40 | }, 800);
41 | }
42 | prevLoadingState.current = isLoading;
43 | }, [isLoading]);
44 |
45 | let hoverBg = 'hover:bg-slate-400';
46 | let hoverBgDark = 'dark:hover:bg-slate-600';
47 | if (subtle) {
48 | hoverBg = 'hover:bg-slate-200';
49 | hoverBgDark = 'dark:hover:bg-slate-700';
50 | } else if (isPrimary) {
51 | hoverBg = 'hover:bg-emerald-600';
52 | hoverBgDark = 'dark:hover:bg-emerald-600';
53 | }
54 |
55 | return (
56 |
75 | {isLoading ? : null}
76 | {isLoadingDone ? (
77 |
78 | ) : null}
79 | {!isLoading && !isLoadingDone ? children : null}
80 |
81 | );
82 | };
83 |
84 | export default Button;
85 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/ButtonGroup/index.tsx:
--------------------------------------------------------------------------------
1 | import { ReactElement } from 'react';
2 | import cx from 'classnames';
3 |
4 | interface Props {
5 | buttons: {
6 | content: ReactElement;
7 | srContent: string;
8 | onClick: () => void;
9 | isActive: boolean;
10 | }[];
11 | }
12 |
13 | export const ButtonGroup = ({ buttons }: Props) => {
14 | return (
15 |
16 | {buttons.map(({ content, srContent, onClick, isActive }, index) => (
17 |
29 | {srContent}
30 | {content}
31 |
32 | ))}
33 |
34 | );
35 | };
36 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/ConfirmDialog/index.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import Button from '../Button';
3 | import Modal from '../Modal';
4 |
5 | export const ConfirmDialog = ({
6 | onClose,
7 | onConfirm,
8 | open,
9 | confirmText,
10 | }: {
11 | onClose?: () => void;
12 | onConfirm?: () => void;
13 | open: boolean;
14 | confirmText?: string;
15 | }) => {
16 | const [isOpen, setIsOpen] = useState(open);
17 |
18 | useEffect(() => {
19 | setIsOpen(open);
20 | }, [open]);
21 |
22 | const handleClose = () => {
23 | if (onClose) {
24 | onClose();
25 | }
26 | setIsOpen(false);
27 | };
28 |
29 | const handleConfirm = () => {
30 | if (onConfirm) {
31 | onConfirm();
32 | }
33 | setIsOpen(false);
34 | };
35 |
36 | return (
37 |
38 |
42 |
43 | {confirmText || 'Are you sure?'}
44 |
45 |
46 |
50 | Confirm
51 |
52 |
56 | Cancel
57 |
58 |
59 |
60 |
61 | );
62 | };
63 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/DeviceManager/PreviewSuites/CreateSuiteButton/CreateSuiteModal.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { useDispatch } from 'react-redux';
3 | import { v4 as uuidv4 } from 'uuid';
4 |
5 | import { addSuite } from 'renderer/store/features/device-manager';
6 |
7 | import Button from '../../../Button';
8 | import Input from '../../../Input';
9 | import Modal from '../../../Modal';
10 |
11 | interface Props {
12 | isOpen: boolean;
13 | onClose: () => void;
14 | }
15 |
16 | export const CreateSuiteModal = ({ isOpen, onClose }: Props) => {
17 | const [name, setName] = useState('');
18 | const dispatch = useDispatch();
19 |
20 | const handleAddSuite = async (): Promise => {
21 | if (name === '') {
22 | // eslint-disable-next-line no-alert
23 | return alert(
24 | 'Suite name cannot be empty. Please enter a name for the suite.'
25 | );
26 | }
27 | dispatch(addSuite({ id: uuidv4(), name, devices: ['10008'] }));
28 | return onClose();
29 | };
30 |
31 | return (
32 | <>
33 |
34 |
35 |
36 | setName(e.target.value)}
42 | />
43 |
44 |
45 |
46 |
47 | Cancel
48 |
49 |
50 | Add
51 |
52 |
53 |
54 |
55 |
56 | >
57 | );
58 | };
59 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/DeviceManager/PreviewSuites/CreateSuiteButton/index.tsx:
--------------------------------------------------------------------------------
1 | import { Icon } from '@iconify/react';
2 | import { useState } from 'react';
3 | import Button from 'renderer/components/Button';
4 | import { CreateSuiteModal } from './CreateSuiteModal';
5 |
6 | export const CreateSuiteButton = () => {
7 | const [open, setOpen] = useState(false);
8 | return (
9 |
10 | Add Suite
11 | setOpen(true)}
14 | >
15 |
16 |
17 | setOpen(false)} />
18 |
19 | );
20 | };
21 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/DeviceManager/PreviewSuites/ManageSuitesTool/ManageSuitesToolError.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen, fireEvent } from '@testing-library/react';
2 | import '@testing-library/jest-dom';
3 | import { ManageSuitesToolError } from './ManageSuitesToolError';
4 |
5 | describe('ManageSuitesToolError', () => {
6 | it('renders the error message and close button', () => {
7 | const onClose = jest.fn();
8 | render( );
9 |
10 | expect(
11 | screen.getByText('There has been an error, please try again.')
12 | ).toBeInTheDocument();
13 | expect(screen.getByText('Close')).toBeInTheDocument();
14 | });
15 |
16 | it('calls onClose when the close button is clicked', () => {
17 | const onClose = jest.fn();
18 | render( );
19 |
20 | fireEvent.click(screen.getByText('Close'));
21 | expect(onClose).toHaveBeenCalledTimes(1);
22 | });
23 | });
24 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/DeviceManager/PreviewSuites/ManageSuitesTool/ManageSuitesToolError.tsx:
--------------------------------------------------------------------------------
1 | import Button from 'renderer/components/Button';
2 |
3 | export const ManageSuitesToolError = ({ onClose }: { onClose: () => void }) => {
4 | return (
5 |
9 |
10 |
There has been an error, please try again.
11 |
12 |
13 | Close
14 |
15 |
16 | );
17 | };
18 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/DeviceManager/PreviewSuites/ManageSuitesTool/helpers.ts:
--------------------------------------------------------------------------------
1 | import { defaultDevices, Device } from 'common/deviceList';
2 |
3 | export const downloadFile = >(
4 | fileData: T
5 | ) => {
6 | const jsonString = JSON.stringify(fileData, null, 2);
7 | const blob = new Blob([jsonString], { type: 'application/json' });
8 | const url = URL.createObjectURL(blob);
9 | const link = document.createElement('a');
10 |
11 | link.href = url;
12 | link.download = `responsively_backup_${new Date().toLocaleDateString()}.json`;
13 |
14 | document.body.appendChild(link);
15 | link.click();
16 | document.body.removeChild(link);
17 | URL.revokeObjectURL(url);
18 | };
19 |
20 | export const setCustomDevices = (customDevices: Device[]) => {
21 | const importedCustomDevices = customDevices.filter(
22 | (item: Device) => !defaultDevices.includes(item)
23 | );
24 |
25 | window.electron.store.set(
26 | 'deviceManager.customDevices',
27 | importedCustomDevices
28 | );
29 |
30 | return importedCustomDevices;
31 | };
32 |
33 | export const onFileDownload = () => {
34 | const fileData = {
35 | customDevices: window.electron.store.get('deviceManager.customDevices'),
36 | suites: window.electron.store.get('deviceManager.previewSuites'),
37 | };
38 | downloadFile(fileData);
39 | };
40 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/DeviceManager/PreviewSuites/ManageSuitesTool/test.json:
--------------------------------------------------------------------------------
1 | {
2 | "customDevices": [
3 | {
4 | "id": "123",
5 | "name": "a new test",
6 | "width": 400,
7 | "height": 600,
8 | "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1",
9 | "typse": "phone",
10 | "dpxxi": 1,
11 | "isTouchCapable": true,
12 | "isMobileCapable": true,
13 | "capabilities": [
14 | "touch",
15 | "mobile"
16 | ],
17 | "isCustom": true
18 | }
19 | ],
20 | "suites": [
21 | {
22 | "id": "default",
23 | "name": "Default",
24 | "devices": [
25 | "10008"
26 | ]
27 | },
28 | {
29 | "id": "a4c142fc-debd-4eaa-beba-aef60093151c",
30 | "name": "my custom suite",
31 | "devices": [
32 | "10008",
33 | "30014"
34 | ]
35 | }
36 | ]
37 | }
38 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/DeviceManager/PreviewSuites/ManageSuitesTool/utils.test.ts:
--------------------------------------------------------------------------------
1 | import { transformFile } from './utils';
2 |
3 | describe('transformFile', () => {
4 | it('should parse JSON content of the file', async () => {
5 | const jsonContent = { key: 'value' };
6 | const file = new Blob([JSON.stringify(jsonContent)], {
7 | type: 'application/json',
8 | }) as File;
9 | Object.defineProperty(file, 'name', { value: 'test.json' });
10 |
11 | const result = await transformFile(file);
12 | expect(result).toEqual(jsonContent);
13 | });
14 |
15 | it('should throw an error for invalid JSON', async () => {
16 | const invalidJsonContent = "{ key: 'value' }"; // Invalid JSON
17 | const file = new Blob([invalidJsonContent], {
18 | type: 'application/json',
19 | }) as File;
20 | Object.defineProperty(file, 'name', { value: 'test.json' });
21 |
22 | await expect(transformFile(file)).rejects.toThrow();
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/DeviceManager/PreviewSuites/ManageSuitesTool/utils.ts:
--------------------------------------------------------------------------------
1 | export const transformFile = (file: File): Promise<{ [key: string]: any }> => {
2 | return new Promise((resolve, reject) => {
3 | const reader = new FileReader();
4 |
5 | reader.onload = () => {
6 | try {
7 | const jsonContent = JSON.parse(reader.result as string);
8 | resolve(jsonContent);
9 | } catch (error) {
10 | reject(error);
11 | }
12 | };
13 |
14 | reader.onerror = () => {
15 | reject(reader.error);
16 | };
17 |
18 | reader.readAsText(file);
19 | });
20 | };
21 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/DeviceManager/PreviewSuites/Suite.tsx:
--------------------------------------------------------------------------------
1 | import { Icon } from '@iconify/react';
2 | import cx from 'classnames';
3 | import { Device, getDevicesMap } from 'common/deviceList';
4 | import { useDrop } from 'react-dnd';
5 | import { useDispatch } from 'react-redux';
6 | import Button from 'renderer/components/Button';
7 | import {
8 | PreviewSuite,
9 | deleteSuite,
10 | setActiveSuite,
11 | setSuiteDevices,
12 | } from 'renderer/store/features/device-manager';
13 | import DeviceLabel, { DND_TYPE } from '../DeviceLabel';
14 |
15 | interface Props {
16 | suite: PreviewSuite;
17 | isActive: boolean;
18 | }
19 |
20 | export const Suite = ({ suite: { id, name, devices }, isActive }: Props) => {
21 | const [, drop] = useDrop(() => ({ accept: DND_TYPE }));
22 | const dispatch = useDispatch();
23 |
24 | const moveDevice = (device: Device, atIndex: number) => {
25 | const newDevices = devices.filter((d) => d !== device.id);
26 | newDevices.splice(atIndex, 0, device.id);
27 | dispatch(setSuiteDevices({ suite: id, devices: newDevices }));
28 | };
29 | return (
30 |
38 | {!isActive ? (
39 |
40 | dispatch(setActiveSuite(id))}
43 | >
44 |
45 |
46 |
47 | ) : null}
48 |
49 |
50 |
{name}
51 | {id !== 'default' ? (
52 |
dispatch(deleteSuite(id))}>
53 |
54 |
55 | ) : null}
56 |
57 |
58 | {devices.map((deviceId) => (
59 | {}}
62 | hideSelectionControls={!isActive}
63 | disableSelectionControls={devices.length === 1}
64 | enableDnd={isActive}
65 | key={deviceId}
66 | moveDevice={moveDevice}
67 | />
68 | ))}
69 |
70 |
71 |
72 | );
73 | };
74 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/DeviceManager/PreviewSuites/index.tsx:
--------------------------------------------------------------------------------
1 | import { Icon } from '@iconify/react';
2 | import { useSelector } from 'react-redux';
3 | import Button from 'renderer/components/Button';
4 | import {
5 | selectActiveSuite,
6 | selectSuites,
7 | } from 'renderer/store/features/device-manager';
8 | import { useState } from 'react';
9 | import { FileUploader } from 'renderer/components/FileUploader';
10 | import Modal from 'renderer/components/Modal';
11 | import { Suite } from './Suite';
12 | import { CreateSuiteButton } from './CreateSuiteButton';
13 | import { ManageSuitesTool } from './ManageSuitesTool/ManageSuitesTool';
14 |
15 | export const PreviewSuites = () => {
16 | const suites = useSelector(selectSuites);
17 | const activeSuite = useSelector(selectActiveSuite);
18 |
19 | return (
20 |
21 |
22 |
23 | {suites.map((suite) => (
24 |
29 | ))}
30 |
31 |
32 |
33 |
34 | );
35 | };
36 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/Divider/index.tsx:
--------------------------------------------------------------------------------
1 | export const Divider = () => (
2 |
3 | );
4 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/FileUploader/FileUploader.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, fireEvent } from '@testing-library/react';
2 | import '@testing-library/jest-dom';
3 | import { FileUploader, FileUploaderProps } from './FileUploader';
4 | import { useFileUpload } from './hooks';
5 |
6 | jest.mock('./hooks');
7 |
8 | const mockHandleFileUpload = jest.fn();
9 | const mockHandleUpload = jest.fn();
10 | const mockResetUploadedFile = jest.fn();
11 |
12 | describe('FileUploader', () => {
13 | beforeEach(() => {
14 | (useFileUpload as jest.Mock).mockReturnValue({
15 | uploadedFile: null,
16 | handleUpload: mockHandleUpload,
17 | resetUploadedFile: mockResetUploadedFile,
18 | });
19 | });
20 |
21 | const renderComponent = (props?: FileUploaderProps) =>
22 | render(
23 |
28 | );
29 |
30 | it('renders the component', () => {
31 | const { getByTestId } = renderComponent();
32 |
33 | const fileInput = getByTestId('fileUploader');
34 |
35 | expect(fileInput).toBeInTheDocument();
36 | });
37 |
38 | it('calls handleUpload when file input changes', () => {
39 | const { getByTestId } = renderComponent();
40 | const fileInput = getByTestId('fileUploader');
41 | fireEvent.change(fileInput, {
42 | target: { files: [new File(['content'], 'file.txt')] },
43 | });
44 | expect(mockHandleUpload).toHaveBeenCalled();
45 | });
46 |
47 | it('calls handleFileUpload when uploadedFile is set', () => {
48 | const mockFile = new File(['content'], 'file.txt');
49 | (useFileUpload as jest.Mock).mockReturnValue({
50 | uploadedFile: mockFile,
51 | handleUpload: mockHandleUpload,
52 | resetUploadedFile: mockResetUploadedFile,
53 | });
54 | renderComponent();
55 | expect(mockHandleFileUpload).toHaveBeenCalledWith(mockFile);
56 | });
57 |
58 | it('sets the accept attribute correctly', () => {
59 | const { getByTestId } = renderComponent({
60 | acceptedFileTypes: 'application/json',
61 | handleFileUpload: mockHandleFileUpload,
62 | });
63 | const fileInput = getByTestId('fileUploader');
64 | expect(fileInput).toHaveAttribute('accept', 'application/json');
65 | });
66 |
67 | it('allows multiple file uploads when multiple prop is true', () => {
68 | const { getByTestId } = renderComponent({
69 | multiple: true,
70 | handleFileUpload: mockHandleFileUpload,
71 | });
72 | const fileInput = getByTestId('fileUploader');
73 | expect(fileInput).toHaveAttribute('multiple');
74 | });
75 |
76 | it('does not allow multiple file uploads when multiple prop is false', () => {
77 | const { getByTestId } = renderComponent({
78 | multiple: false,
79 | handleFileUpload: mockHandleFileUpload,
80 | });
81 | const fileInput = getByTestId('fileUploader');
82 | expect(fileInput).not.toHaveAttribute('multiple');
83 | });
84 | });
85 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/FileUploader/FileUploader.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react';
2 | import { useFileUpload } from './hooks';
3 |
4 | export type FileUploaderProps = {
5 | handleFileUpload: (file: File) => void;
6 | multiple?: boolean;
7 | acceptedFileTypes?: string;
8 | };
9 |
10 | export const FileUploader = ({
11 | handleFileUpload,
12 | multiple,
13 | acceptedFileTypes,
14 | }: FileUploaderProps) => {
15 | const { uploadedFile, handleUpload, resetUploadedFile } = useFileUpload();
16 | const fileInputRef = useRef(null);
17 |
18 | useEffect(() => {
19 | if (uploadedFile) {
20 | handleFileUpload(uploadedFile);
21 | resetUploadedFile();
22 | if (fileInputRef.current) {
23 | fileInputRef.current.value = '';
24 | }
25 | }
26 | }, [handleFileUpload, resetUploadedFile, uploadedFile]);
27 |
28 | return (
29 |
30 |
39 |
40 | );
41 | };
42 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/FileUploader/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export { useFileUpload } from './useFileUpload';
2 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/FileUploader/hooks/useFileUpload.test.tsx:
--------------------------------------------------------------------------------
1 | import { act, renderHook } from '@testing-library/react';
2 | import { useFileUpload } from './useFileUpload';
3 |
4 | describe('useFileUpload', () => {
5 | it('should initialize with null uploadedFile', () => {
6 | const { result } = renderHook(() => useFileUpload());
7 |
8 | expect(result.current.uploadedFile).toBeNull();
9 | });
10 |
11 | it('should set uploadedFile when handleUpload is called with a file', () => {
12 | const { result } = renderHook(() => useFileUpload());
13 | const mockFile = new File(['dummy content'], 'example.png', {
14 | type: 'image/png',
15 | });
16 |
17 | act(() => {
18 | result.current.handleUpload({
19 | target: {
20 | files: [mockFile],
21 | },
22 | } as unknown as React.ChangeEvent);
23 | });
24 |
25 | expect(result.current.uploadedFile).toEqual(mockFile);
26 | });
27 |
28 | it('should reset uploadedFile when resetUploadedFile is called', () => {
29 | const { result } = renderHook(() => useFileUpload());
30 | const mockFile = new File(['dummy content'], 'example.png', {
31 | type: 'image/png',
32 | });
33 |
34 | act(() => {
35 | result.current.handleUpload({
36 | target: {
37 | files: [mockFile],
38 | },
39 | } as unknown as React.ChangeEvent);
40 | });
41 |
42 | expect(result.current.uploadedFile).toEqual(mockFile);
43 |
44 | act(() => {
45 | result.current.resetUploadedFile();
46 | });
47 |
48 | expect(result.current.uploadedFile).toBeNull();
49 | });
50 | });
51 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/FileUploader/hooks/useFileUpload.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 |
3 | export const useFileUpload = () => {
4 | const [uploadedFile, setUploadedFile] = useState(null);
5 |
6 | const handleUpload = (event: React.ChangeEvent) => {
7 | if (event?.target?.files || event?.target?.files?.length) {
8 | setUploadedFile(event.target.files[0]);
9 | }
10 | };
11 |
12 | const resetUploadedFile = () => {
13 | setUploadedFile(null);
14 | };
15 |
16 | return {
17 | uploadedFile,
18 | handleUpload,
19 | resetUploadedFile,
20 | };
21 | };
22 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/FileUploader/index.ts:
--------------------------------------------------------------------------------
1 | export { FileUploader } from './FileUploader';
2 | export { useFileUpload } from './hooks';
3 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/InfoPopups/index.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { isReleaseNotesUnseen, ReleaseNotes } from '../ReleaseNotes';
3 | import { Sponsorship } from '../Sponsorship';
4 |
5 | export const InfoPopups = () => {
6 | const [showReleaseNotes, setShowReleaseNotes] = useState(false);
7 | const [showSponsorship, setShowSponsorship] = useState(false);
8 |
9 | useEffect(() => {
10 | (async () => {
11 | if (await isReleaseNotesUnseen()) {
12 | setShowReleaseNotes(true);
13 | return;
14 | }
15 | setShowSponsorship(true);
16 | })();
17 | }, []);
18 |
19 | if (showReleaseNotes) {
20 | return ;
21 | }
22 |
23 | if (showSponsorship) {
24 | return ;
25 | }
26 |
27 | return null;
28 | };
29 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/Input/index.tsx:
--------------------------------------------------------------------------------
1 | import { useId } from 'react';
2 | import cx from 'classnames';
3 |
4 | interface Props {
5 | label: string;
6 | }
7 |
8 | const Input = ({
9 | label,
10 | ...props
11 | }: Props &
12 | React.DetailedHTMLProps<
13 | React.InputHTMLAttributes,
14 | HTMLInputElement
15 | >) => {
16 | const id = useId();
17 | const isCheckbox = props.type === 'checkbox';
18 | return (
19 |
25 | {label}
26 |
33 |
34 | );
35 | };
36 |
37 | export default Input;
38 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/KeyboardShortcutsManager/constants.ts:
--------------------------------------------------------------------------------
1 | export const SHORTCUT_CHANNEL = {
2 | BACK: 'BACK',
3 | BOOKMARK: 'BOOKMARK',
4 | DELETE_ALL: 'DELETE_ALL',
5 | DELETE_CACHE: 'DELETE_CACHE',
6 | DELETE_COOKIES: 'DELETE_COOKIES',
7 | DELETE_STORAGE: 'DELETE_STORAGE',
8 | EDIT_URL: 'EDIT_URL',
9 | FORWARD: 'FORWARD',
10 | INSPECT_ELEMENTS: 'INSPECT_ELEMENTS',
11 | PREVIEW_LAYOUT: 'PREVIEW_LAYOUT',
12 | RELOAD: 'RELOAD',
13 | ROTATE_ALL: 'ROTATE_ALL',
14 | SCREENSHOT_ALL: 'SCREENSHOT_ALL',
15 | THEME: 'THEME',
16 | TOGGLE_RULERS: 'TOGGLE_RULERS',
17 | ZOOM_IN: 'ZOOM_IN',
18 | ZOOM_OUT: 'ZOOM_OUT',
19 | } as const;
20 |
21 | export type ShortcutChannel =
22 | typeof SHORTCUT_CHANNEL[keyof typeof SHORTCUT_CHANNEL];
23 |
24 | export const SHORTCUT_KEYS: { [key in ShortcutChannel]: string[] } = {
25 | [SHORTCUT_CHANNEL.BACK]: ['alt+left'],
26 | [SHORTCUT_CHANNEL.BOOKMARK]: ['mod+d'],
27 | [SHORTCUT_CHANNEL.DELETE_ALL]: ['mod+alt+del', 'mod+alt+backspace'],
28 | [SHORTCUT_CHANNEL.DELETE_CACHE]: ['mod+alt+z'],
29 | [SHORTCUT_CHANNEL.DELETE_COOKIES]: ['mod+alt+a'],
30 | [SHORTCUT_CHANNEL.DELETE_STORAGE]: ['mod+alt+q'],
31 | [SHORTCUT_CHANNEL.EDIT_URL]: ['mod+l'],
32 | [SHORTCUT_CHANNEL.FORWARD]: ['alt+right'],
33 | [SHORTCUT_CHANNEL.INSPECT_ELEMENTS]: ['mod+i'],
34 | [SHORTCUT_CHANNEL.PREVIEW_LAYOUT]: ['mod+shift+l'],
35 | [SHORTCUT_CHANNEL.RELOAD]: ['mod+r'],
36 | [SHORTCUT_CHANNEL.ROTATE_ALL]: ['mod+alt+r'],
37 | [SHORTCUT_CHANNEL.SCREENSHOT_ALL]: ['mod+s'],
38 | [SHORTCUT_CHANNEL.THEME]: ['mod+t'],
39 | [SHORTCUT_CHANNEL.TOGGLE_RULERS]: ['alt+r'],
40 | [SHORTCUT_CHANNEL.ZOOM_IN]: ['mod+=', 'mod++', 'mod+shift+='],
41 | [SHORTCUT_CHANNEL.ZOOM_OUT]: ['mod+-'],
42 | };
43 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/KeyboardShortcutsManager/index.tsx:
--------------------------------------------------------------------------------
1 | import { SHORTCUT_KEYS, ShortcutChannel } from './constants';
2 | import useMousetrapEmitter from './useMousetrapEmitter';
3 |
4 | const KeyboardShortcutsManager = () => {
5 | // eslint-disable-next-line no-restricted-syntax
6 | for (const [channel, keys] of Object.entries(SHORTCUT_KEYS)) {
7 | // eslint-disable-next-line react-hooks/rules-of-hooks
8 | useMousetrapEmitter(keys, channel as ShortcutChannel);
9 | }
10 |
11 | return null;
12 | };
13 |
14 | export default KeyboardShortcutsManager;
15 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/KeyboardShortcutsManager/useKeyboardShortcut.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import { ShortcutChannel } from './constants';
3 | import { keyboardShortcutsPubsub } from './useMousetrapEmitter';
4 |
5 | const useKeyboardShortcut = (
6 | eventChannel: ShortcutChannel,
7 | callback: () => void
8 | ) => {
9 | useEffect(() => {
10 | keyboardShortcutsPubsub.subscribe(eventChannel, callback);
11 | return () => {
12 | keyboardShortcutsPubsub.unsubscribe(eventChannel, callback);
13 | };
14 | }, [eventChannel, callback]);
15 | return null;
16 | };
17 |
18 | export default useKeyboardShortcut;
19 | export * from './constants';
20 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/KeyboardShortcutsManager/useMousetrapEmitter.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import Mousetrap from 'mousetrap';
3 | import PubSub from 'renderer/lib/pubsub';
4 | import { ShortcutChannel } from './constants';
5 |
6 | export const keyboardShortcutsPubsub = new PubSub();
7 |
8 | const useMousetrapEmitter = (
9 | accelerator: string | string[],
10 | eventChannel: ShortcutChannel,
11 | action?: string | undefined
12 | ) => {
13 | useEffect(() => {
14 | const callback = async (
15 | _e: Mousetrap.ExtendedKeyboardEvent,
16 | _combo: string
17 | ) => {
18 | try {
19 | await keyboardShortcutsPubsub.publish(eventChannel);
20 | } catch (err) {
21 | // eslint-disable-next-line no-console
22 | console.error('useMousetrapEmitter: callback: error: ', err);
23 | }
24 | };
25 | Mousetrap.bind(accelerator, callback, action);
26 |
27 | return () => {
28 | Mousetrap.unbind(accelerator, action);
29 | };
30 | }, [accelerator, eventChannel, action]);
31 |
32 | return null;
33 | };
34 |
35 | export default useMousetrapEmitter;
36 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/Modal/index.tsx:
--------------------------------------------------------------------------------
1 | import { Dialog, Transition } from '@headlessui/react';
2 | import { Fragment } from 'react';
3 |
4 | interface Props {
5 | isOpen: boolean;
6 | onClose: () => void;
7 | title?: JSX.Element | string;
8 | description?: JSX.Element | string;
9 | children?: JSX.Element | string;
10 | }
11 |
12 | const Modal = ({ isOpen, onClose, title, description, children }: Props) => {
13 | return (
14 |
15 |
16 |
25 |
26 |
27 |
28 |
29 |
38 |
43 |
44 |
45 | {title}
46 |
47 | {description}
48 |
49 |
50 | {children}
51 |
52 |
53 |
54 |
55 |
56 |
57 | );
58 | };
59 |
60 | export default Modal;
61 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/ModalLoader/index.tsx:
--------------------------------------------------------------------------------
1 | import Modal from '../Modal';
2 |
3 | interface Props {
4 | isOpen: boolean;
5 | onClose: () => void;
6 | title: JSX.Element | string;
7 | }
8 |
9 | const ModalLoader = ({ isOpen, onClose, title }: Props) => {
10 | return (
11 |
12 |
13 | Capturing screen...
14 |
15 |
16 | );
17 | };
18 |
19 | export default ModalLoader;
20 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/Notifications/Notification.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | IPC_MAIN_CHANNELS,
3 | Notification as NotificationType,
4 | } from 'common/constants';
5 | import Button from '../Button';
6 |
7 | const Notification = ({ notification }: { notification: NotificationType }) => {
8 | const handleLinkClick = (url: string) => {
9 | window.electron.ipcRenderer.sendMessage(IPC_MAIN_CHANNELS.OPEN_EXTERNAL, {
10 | url,
11 | });
12 | };
13 |
14 | return (
15 |
16 |
{notification.text}
17 | {notification.link && notification.linkText && (
18 |
22 | notification.link && handleLinkClick(notification.link)
23 | }
24 | className="mt-2"
25 | >
26 | {notification.linkText}
27 |
28 | )}
29 |
30 | );
31 | };
32 |
33 | export default Notification;
34 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/Notifications/NotificationEmptyStatus.tsx:
--------------------------------------------------------------------------------
1 | const NotificationEmptyStatus = () => {
2 | return (
3 |
4 |
You are all caught up! No new notifications at the moment.
5 |
6 | );
7 | };
8 |
9 | export default NotificationEmptyStatus;
10 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/Notifications/Notifications.tsx:
--------------------------------------------------------------------------------
1 | import { useSelector } from 'react-redux';
2 | import { selectNotifications } from 'renderer/store/features/renderer';
3 | import { v4 as uuidv4 } from 'uuid';
4 | import { Notification as NotificationType } from 'common/constants';
5 | import Notification from './Notification';
6 | import NotificationEmptyStatus from './NotificationEmptyStatus';
7 |
8 | const Notifications = () => {
9 | const notificationsState = useSelector(selectNotifications);
10 |
11 | return (
12 |
13 |
Notifications
14 |
15 | {(!notificationsState ||
16 | (notificationsState && notificationsState?.length === 0)) && (
17 |
18 | )}
19 | {notificationsState &&
20 | notificationsState?.length > 0 &&
21 | notificationsState?.map((notification: NotificationType) => (
22 |
23 | ))}
24 |
25 |
26 | );
27 | };
28 |
29 | export default Notifications;
30 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/Notifications/NotificationsBubble.tsx:
--------------------------------------------------------------------------------
1 | const NotificationsBubble = () => {
2 | return (
3 |
4 |
5 |
6 |
7 | );
8 | };
9 |
10 | export default NotificationsBubble;
11 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/Previewer/Device/common.ts:
--------------------------------------------------------------------------------
1 | interface ContextMenuMetadata {
2 | id: string;
3 | label: string;
4 | }
5 |
6 | export const CONTEXT_MENUS: { [key: string]: ContextMenuMetadata } = {
7 | INSPECT_ELEMENT: { id: 'INSPECT_ELEMENT', label: 'Inspect Element' },
8 | OPEN_CONSOLE: { id: 'OPEN_CONSOLE', label: 'Open Console' },
9 | };
10 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/Previewer/Device/utils.ts:
--------------------------------------------------------------------------------
1 | import { HistoryItem } from 'renderer/components/ToolBar/AddressBar/SuggestionList';
2 |
3 | // eslint-disable-next-line import/prefer-default-export
4 | export const appendHistory = (url: string, title: string) => {
5 | if (url === `${title}/`) {
6 | return;
7 | }
8 | const history: HistoryItem[] = window.electron.store.get('history');
9 | window.electron.store.set(
10 | 'history',
11 | [
12 | { url, title, lastVisited: new Date().getTime() },
13 | ...history.filter(({ url: _url }) => url !== _url),
14 | ].slice(0, 100)
15 | );
16 | };
17 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/Previewer/Guides/guide.css:
--------------------------------------------------------------------------------
1 | .box {
2 | position: absolute;
3 | top: 0;
4 | left: 0;
5 | width: 30px;
6 | height: 30px;
7 | box-sizing: border-box;
8 | background: transparent;
9 | z-index: 21;
10 | }
11 |
12 | .box:before,
13 | .box:after {
14 | position: absolute;
15 | content: '';
16 | /*background: rgb(55, 65, 81);*/
17 | }
18 |
19 | .box:before {
20 | width: 1px;
21 | height: 100%;
22 | left: 100%;
23 | }
24 |
25 | .box:after {
26 | height: 1px;
27 | width: 100%;
28 | top: 100%;
29 | }
30 |
31 | .scena-guides-horizontal .scena-guides-guide-pos {
32 | left: 48% !important;
33 | }
34 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/Previewer/IndividualLayoutToolBar/index.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { useDispatch } from 'react-redux';
3 | import { Tab, Tabs, TabList } from 'react-tabs';
4 | import { Icon } from '@iconify/react';
5 | import cx from 'classnames';
6 | import { setLayout } from 'renderer/store/features/renderer';
7 | import { PREVIEW_LAYOUTS } from 'common/constants';
8 | import { Device as IDevice } from 'common/deviceList';
9 | import './styles.css';
10 |
11 | interface Props {
12 | individualDevice: IDevice;
13 | setIndividualDevice: (device: IDevice) => void;
14 | devices: IDevice[];
15 | }
16 |
17 | const IndividualLayoutToolbar = ({
18 | individualDevice,
19 | setIndividualDevice,
20 | devices,
21 | }: Props) => {
22 | const dispatch = useDispatch();
23 | const [activeTab, setActiveTab] = useState(0);
24 |
25 | const onTabClick = (newTabIndex: number) => {
26 | setActiveTab(newTabIndex);
27 | setIndividualDevice(devices[newTabIndex]);
28 | };
29 |
30 | const isActive = (idx: number) => activeTab === idx;
31 | const handleCloseBtn = () => dispatch(setLayout(PREVIEW_LAYOUTS.COLUMN));
32 |
33 | useEffect(() => {
34 | const activeTabIndex = devices.findIndex(
35 | (device) => device.id === individualDevice.id
36 | );
37 | setActiveTab(activeTabIndex);
38 | }, [individualDevice, devices]);
39 |
40 | return (
41 |
42 |
47 |
52 | {devices.map((device, idx) => (
53 |
64 | {device.name}
65 |
66 | ))}
67 |
68 |
69 |
70 |
76 |
77 |
78 | );
79 | };
80 |
81 | export default IndividualLayoutToolbar;
82 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/Previewer/IndividualLayoutToolBar/styles.css:
--------------------------------------------------------------------------------
1 | .custom-scrollbar::-webkit-scrollbar,
2 | .custom-scrollbar::-webkit-scrollbar:hover,
3 | .dark .custom-scrollbar::-webkit-scrollbar,
4 | .dark .custom-scrollbar::-webkit-scrollbar:hover {
5 | width: 0.25rem;
6 | height: 0.25rem;
7 | border-radius: 6px;
8 | }
9 |
10 | .custom-scrollbar::-webkit-scrollbar {
11 | background-color: rgba(255, 255, 255, 0.9);
12 | }
13 |
14 | .custom-scrollbar::-webkit-scrollbar-thumb,
15 | .custom-scrollbar::-webkit-scrollbar-thumb:hover {
16 | background-color: rgba(148, 163, 184, 0.7);
17 | }
18 |
19 | .dark .custom-scrollbar::-webkit-scrollbar {
20 | background-color: rgba(148, 163, 184, 0.6);
21 | }
22 |
23 | .dark .custom-scrollbar::-webkit-scrollbar-thumb,
24 | .dark .custom-scrollbar::-webkit-scrollbar-thumb:hover {
25 | background-color: rgba(241, 245, 249, 0.8);
26 | }
27 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/Select/index.tsx:
--------------------------------------------------------------------------------
1 | import { useId } from 'react';
2 |
3 | interface Props {
4 | label: string;
5 | }
6 |
7 | const Select = ({
8 | label,
9 | ...props
10 | }: Props &
11 | React.DetailedHTMLProps<
12 | React.SelectHTMLAttributes,
13 | HTMLSelectElement
14 | >) => {
15 | const id = useId();
16 | return (
17 |
18 | {label}
19 |
25 | {props.children}
26 |
27 |
28 | );
29 | };
30 |
31 | export default Select;
32 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/Spinner/index.tsx:
--------------------------------------------------------------------------------
1 | import { Icon } from '@iconify/react';
2 |
3 | interface Props {
4 | spinnerHeight?: number;
5 | }
6 |
7 | const Spinner = ({ spinnerHeight = undefined }: Props) => {
8 | return (
9 |
10 |
11 |
12 | );
13 | };
14 |
15 | export default Spinner;
16 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/Toggle/index.tsx:
--------------------------------------------------------------------------------
1 | interface Props {
2 | isOn: boolean;
3 | onChange?: React.ChangeEventHandler;
4 | }
5 |
6 | const Toggle = ({ isOn, onChange }: Props) => {
7 | return (
8 | // eslint-disable-next-line jsx-a11y/label-has-associated-control
9 |
10 |
17 |
18 |
19 | );
20 | };
21 |
22 | export default Toggle;
23 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/ToolBar/AddressBar/AuthModal.tsx:
--------------------------------------------------------------------------------
1 | import { IPC_MAIN_CHANNELS } from 'common/constants';
2 | import { AuthInfo } from 'electron';
3 | import { AuthResponseArgs } from 'main/http-basic-auth';
4 | import { useEffect, useState } from 'react';
5 | import Button from 'renderer/components/Button';
6 | import Input from 'renderer/components/Input';
7 | import Modal from 'renderer/components/Modal';
8 |
9 | interface Props {
10 | isOpen: boolean;
11 | onClose: () => void;
12 | authInfo: AuthInfo | null;
13 | }
14 |
15 | const AuthModal = ({ isOpen, onClose, authInfo }: Props) => {
16 | const [username, setUsername] = useState('');
17 | const [password, setPassword] = useState('');
18 |
19 | useEffect(() => {
20 | if (!isOpen) {
21 | setUsername('');
22 | setPassword('');
23 | }
24 | }, [isOpen]);
25 |
26 | const onSubmit = (proceed: boolean) => {
27 | if (authInfo == null) {
28 | return;
29 | }
30 | window.electron.ipcRenderer.sendMessage(
31 | IPC_MAIN_CHANNELS.AUTH_RESPONSE,
32 | {
33 | authInfo,
34 | username: proceed ? username : '',
35 | password: proceed ? password : '',
36 | }
37 | );
38 | onClose();
39 | };
40 |
41 | return (
42 |
43 |
44 |
45 | Authentication request for{' '}
46 | {authInfo?.host}
47 |
48 |
49 | setUsername(e.target.value)}
53 | />
54 | setPassword(e.target.value)}
59 | />
60 |
61 |
62 |
63 | onSubmit(false)}>
64 | Cancel
65 |
66 | onSubmit(true)} isActive>
67 | Proceed
68 |
69 |
70 |
71 |
72 | );
73 | };
74 |
75 | export default AuthModal;
76 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/ToolBar/AddressBar/BookmarkButton.tsx:
--------------------------------------------------------------------------------
1 | import { Icon } from '@iconify/react';
2 | import { useState, useMemo } from 'react';
3 | import { useDetectClickOutside } from 'react-detect-click-outside';
4 | import { useDispatch, useSelector } from 'react-redux';
5 | import cx from 'classnames';
6 | import Button from 'renderer/components/Button';
7 |
8 | import {
9 | IBookmarks,
10 | addBookmark,
11 | selectBookmarks,
12 | } from 'renderer/store/features/bookmarks';
13 | import useKeyboardShortcut, {
14 | SHORTCUT_CHANNEL,
15 | } from 'renderer/components/KeyboardShortcutsManager/useKeyboardShortcut';
16 | import BookmarkFlyout from '../Menu/Flyout/Bookmark/ViewAllBookmarks/BookmarkFlyout';
17 |
18 | interface Props {
19 | currentAddress: string;
20 | pageTitle: string;
21 | }
22 |
23 | const BookmarkButton = ({ currentAddress, pageTitle }: Props) => {
24 | const [openFlyout, setOpenFlyout] = useState(false);
25 | const dispatch = useDispatch();
26 | const ref = useDetectClickOutside({
27 | onTriggered: () => {
28 | if (!openFlyout) return;
29 | if (openFlyout) setOpenFlyout(false);
30 | },
31 | });
32 |
33 | const initbookmark = {
34 | id: '',
35 | name: pageTitle,
36 | address: currentAddress,
37 | };
38 |
39 | const bookmarks = useSelector(selectBookmarks);
40 | const bookmarkFound = useMemo(
41 | () => bookmarks.find((bm: IBookmarks) => bm.address === currentAddress),
42 | [currentAddress, bookmarks]
43 | );
44 |
45 | const isPageBookmarked = !!bookmarkFound;
46 |
47 | const handleFlyout = () => {
48 | setOpenFlyout(!openFlyout);
49 | };
50 |
51 | const handleKeyboardShortcut = () => {
52 | handleFlyout();
53 | dispatch(addBookmark(bookmarkFound || initbookmark));
54 | };
55 |
56 | useKeyboardShortcut(SHORTCUT_CHANNEL.BOOKMARK, handleKeyboardShortcut);
57 |
58 | return (
59 |
60 |
61 |
68 |
71 |
72 |
73 |
74 |
75 | {openFlyout && (
76 |
80 | )}
81 |
82 |
83 | );
84 | };
85 |
86 | export default BookmarkButton;
87 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/ToolBar/AddressBar/SuggestionList.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useMemo, useState } from 'react';
2 | import cx from 'classnames';
3 |
4 | export interface HistoryItem {
5 | title: string;
6 | url: string;
7 | lastVisited: number;
8 | }
9 |
10 | interface Props {
11 | match: string;
12 | onEnter: (url?: string) => void;
13 | }
14 |
15 | const SuggestionList = ({ match, onEnter }: Props) => {
16 | const [activeIndex, setActiveIndex] = useState(0);
17 | const [history] = useState(
18 | window.electron.store.get('history')
19 | );
20 |
21 | const suggestions = useMemo(() => {
22 | return history
23 | .filter((item) => {
24 | return `${item.title}-${item.url}`
25 | .toLowerCase()
26 | .includes(match.toLowerCase());
27 | })
28 | .slice(0, 10);
29 | }, [match, history]);
30 |
31 | const keyDownHandler = useCallback(
32 | (e: KeyboardEvent) => {
33 | if (e.key === 'Enter') {
34 | onEnter(
35 | suggestions[activeIndex] != null
36 | ? suggestions[activeIndex].url
37 | : undefined
38 | );
39 | return;
40 | }
41 | if (e.key === 'ArrowUp') {
42 | if (activeIndex === 0) {
43 | return;
44 | }
45 | setActiveIndex(activeIndex - 1);
46 | }
47 | if (e.key === 'ArrowDown') {
48 | if (activeIndex === suggestions.length - 1) {
49 | return;
50 | }
51 | setActiveIndex(activeIndex + 1);
52 | }
53 | },
54 | [activeIndex, suggestions, onEnter]
55 | );
56 |
57 | useEffect(() => {
58 | document.addEventListener('keydown', keyDownHandler);
59 | return () => {
60 | document.removeEventListener('keydown', keyDownHandler);
61 | };
62 | }, [keyDownHandler]);
63 |
64 | return (
65 |
66 | {suggestions.map(({ title, url }, idx) => (
67 |
{
69 | onEnter(url);
70 | }}
71 | className={cx(
72 | 'pointer-events-auto flex w-full items-center gap-2 py-1 pl-2 pr-8 hover:bg-slate-200 dark:hover:bg-slate-700',
73 | { 'bg-slate-200 dark:bg-slate-700': activeIndex === idx }
74 | )}
75 | type="button"
76 | key={url}
77 | >
78 |
79 |
84 |
85 |
86 |
87 | {title}
88 |
89 | -
90 |
91 | {url}
92 |
93 |
94 |
95 | ))}
96 |
97 | );
98 | };
99 |
100 | export default SuggestionList;
101 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/ToolBar/ColorBlindnessControls/index.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { VisionSimulationDropDown } from 'renderer/components/VisionSimulationDropDown';
3 | import { webViewPubSub } from 'renderer/lib/pubsub';
4 |
5 | export const COLOR_BLINDNESS_CHANNEL = 'color-blindness';
6 |
7 | export const ColorBlindnessControls = () => {
8 | const [simulationName, setSimulationName] = useState(
9 | undefined
10 | );
11 |
12 | useEffect(() => {
13 | webViewPubSub.publish(COLOR_BLINDNESS_CHANNEL, { simulationName });
14 | }, [simulationName]);
15 |
16 | return (
17 |
21 | );
22 | };
23 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/ToolBar/ColorSchemeToggle/index.tsx:
--------------------------------------------------------------------------------
1 | import { Icon } from '@iconify/react';
2 | import {
3 | SetNativeThemeArgs,
4 | SetNativeThemeResult,
5 | } from 'main/native-functions';
6 | import { useState } from 'react';
7 | import Button from 'renderer/components/Button';
8 |
9 | const ColorSchemeToggle = () => {
10 | const [isDarkColorScheme, setIsDarkColorScheme] = useState(false);
11 |
12 | return (
13 | {
15 | window.electron.ipcRenderer.invoke<
16 | SetNativeThemeArgs,
17 | SetNativeThemeResult
18 | >('set-native-theme', {
19 | theme: isDarkColorScheme ? 'light' : 'dark',
20 | });
21 | setIsDarkColorScheme(!isDarkColorScheme);
22 | }}
23 | subtle
24 | title="Device theme color toggle"
25 | >
26 |
27 |
28 |
33 |
34 |
35 | );
36 | };
37 |
38 | export default ColorSchemeToggle;
39 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/ToolBar/Menu/Flyout/AllowInSecureSSL/index.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import Toggle from 'renderer/components/Toggle';
3 |
4 | const AllowInSecureSSL = () => {
5 | const [allowed, setAllowed] = useState(
6 | window.electron.store.get('userPreferences.allowInsecureSSLConnections')
7 | );
8 |
9 | return (
10 |
11 |
Allow Insecure SSL
12 |
13 | {
16 | setAllowed(value.target.checked);
17 | window.electron.store.set(
18 | 'userPreferences.allowInsecureSSLConnections',
19 | value.target.checked
20 | );
21 | }}
22 | />
23 |
24 |
25 | );
26 | };
27 |
28 | export default AllowInSecureSSL;
29 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/ToolBar/Menu/Flyout/Bookmark/ViewAllBookmarks/BookmarkFlyout.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { useDispatch } from 'react-redux';
3 | import Button from 'renderer/components/Button';
4 | import {
5 | IBookmarks,
6 | addBookmark,
7 | removeBookmark,
8 | } from 'renderer/store/features/bookmarks';
9 | import Input from 'renderer/components/Input';
10 |
11 | interface Props {
12 | bookmark: IBookmarks;
13 | setOpenFlyout: (bool: boolean) => void;
14 | }
15 |
16 | const BookmarkFlyout = ({ bookmark, setOpenFlyout }: Props) => {
17 | const [currentBookmark, setCurrentBookmark] = useState(bookmark);
18 | const dispatch = useDispatch();
19 |
20 | const handleButton = (e: React.MouseEvent) => {
21 | const target = e.target as HTMLButtonElement;
22 | const buttonType = target.id;
23 |
24 | if (buttonType === 'add') dispatch(addBookmark(currentBookmark));
25 | else dispatch(removeBookmark(currentBookmark));
26 |
27 | setOpenFlyout(false);
28 | };
29 |
30 | const handleChange = (e: React.ChangeEvent) => {
31 | const target = e.target as HTMLButtonElement;
32 | const inputType = target.id;
33 | const inputValue = target.value;
34 |
35 | setCurrentBookmark((prevBookmark) => ({
36 | ...prevBookmark,
37 | [inputType]: inputValue,
38 | }));
39 | };
40 |
41 | useEffect(() => {
42 | setCurrentBookmark(bookmark);
43 | }, [bookmark]);
44 |
45 | return (
46 | <>
47 |
80 | >
81 | );
82 | };
83 |
84 | export default BookmarkFlyout;
85 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/ToolBar/Menu/Flyout/Bookmark/ViewAllBookmarks/BookmarkListButton.tsx:
--------------------------------------------------------------------------------
1 | import cx from 'classnames';
2 | import Button from 'renderer/components/Button';
3 | import { IBookmarks } from 'renderer/store/features/bookmarks';
4 | import { Icon } from '@iconify/react';
5 | import { useState } from 'react';
6 |
7 | export interface Props {
8 | bookmark: IBookmarks;
9 | handleBookmarkClick: (address: string) => void;
10 | setCurrentBookmark: (bookmark: IBookmarks) => void;
11 | setOpenFlyout: (bool: boolean) => void;
12 | }
13 |
14 | const BookmarkListButton = ({
15 | bookmark,
16 | handleBookmarkClick,
17 | setCurrentBookmark,
18 | setOpenFlyout,
19 | }: Props) => {
20 | const [isHovered, setIsHovered] = useState(false);
21 |
22 | return (
23 | setIsHovered(true)}
26 | onMouseLeave={() => setIsHovered(false)}
27 | key={bookmark.id}
28 | >
29 | handleBookmarkClick(bookmark.address)}
33 | >
34 | {bookmark.name}
35 |
36 | {
43 | setCurrentBookmark(bookmark);
44 | setOpenFlyout(true);
45 | }}
46 | >
47 |
48 |
49 |
50 | );
51 | };
52 |
53 | export default BookmarkListButton;
54 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/ToolBar/Menu/Flyout/Bookmark/ViewAllBookmarks/index.tsx:
--------------------------------------------------------------------------------
1 | import { useDispatch } from 'react-redux';
2 | import Button from 'renderer/components/Button';
3 | import { IBookmarks } from 'renderer/store/features/bookmarks';
4 | import { setAddress } from 'renderer/store/features/renderer';
5 | import { useState } from 'react';
6 | import BookmarkListButton from './BookmarkListButton';
7 | import BookmarkFlyout from './BookmarkFlyout';
8 |
9 | export interface Props {
10 | bookmarks: IBookmarks[];
11 | handleBookmarkFlyout: () => void;
12 | }
13 |
14 | const ViewAllBookmarks = ({ bookmarks, handleBookmarkFlyout }: Props) => {
15 | const [currentBookmark, setCurrentBookmark] = useState({
16 | id: '',
17 | name: '',
18 | address: '',
19 | });
20 | const [openFlyout, setOpenFlyout] = useState(false);
21 | const dispatch = useDispatch();
22 |
23 | const areBookmarksPresent = bookmarks.length > 0;
24 |
25 | const handleBookmarkClick = (address: string) => {
26 | dispatch(setAddress(address));
27 | handleBookmarkFlyout();
28 | };
29 |
30 | return (
31 |
32 |
33 | {bookmarks.map((bookmark) => {
34 | return (
35 |
36 |
42 |
43 | );
44 | })}
45 | {!areBookmarksPresent && (
46 |
47 | No bookmarks found{' '}
48 |
49 | )}
50 |
51 |
52 | {openFlyout && (
53 |
57 | )}
58 |
59 |
60 | );
61 | };
62 |
63 | export default ViewAllBookmarks;
64 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/ToolBar/Menu/Flyout/Bookmark/index.tsx:
--------------------------------------------------------------------------------
1 | import { Icon } from '@iconify/react';
2 | import { useEffect, useState } from 'react';
3 | import Button from 'renderer/components/Button';
4 | import { closeMenuFlyout, selectMenuFlyout } from 'renderer/store/features/ui';
5 | import { useDispatch, useSelector } from 'react-redux';
6 | import { selectBookmarks } from 'renderer/store/features/bookmarks';
7 | import ViewAllBookmarks from './ViewAllBookmarks';
8 |
9 | const Bookmark = () => {
10 | const [isOpen, setIsOpen] = useState(false);
11 | const dispatch = useDispatch();
12 | const menuFlyout = useSelector(selectMenuFlyout);
13 | const bookmarks = useSelector(selectBookmarks);
14 |
15 | const handleBookmarkFlyout = () => {
16 | setIsOpen(!isOpen);
17 | dispatch(closeMenuFlyout(!isOpen));
18 | };
19 |
20 | useEffect(() => {
21 | if (!menuFlyout) setIsOpen(false);
22 | }, [menuFlyout]);
23 |
24 | return (
25 | setIsOpen(true)}
28 | onMouseLeave={() => setIsOpen(false)}
29 | >
30 |
31 |
32 |
36 | Bookmarks
37 |
42 |
43 |
44 |
45 | {isOpen && (
46 |
50 | )}
51 |
52 | );
53 | };
54 |
55 | export default Bookmark;
56 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/ToolBar/Menu/Flyout/ClearHistory/index.tsx:
--------------------------------------------------------------------------------
1 | import { Icon } from '@iconify/react';
2 | import Button from 'renderer/components/Button';
3 |
4 | const ClearHistory = () => {
5 | return (
6 |
7 |
Clear History
8 |
9 | {
12 | window.electron.store.set('history', []);
13 | }}
14 | >
15 |
16 |
17 |
18 |
19 | );
20 | };
21 |
22 | export default ClearHistory;
23 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/ToolBar/Menu/Flyout/Devtools/index.tsx:
--------------------------------------------------------------------------------
1 | import { DOCK_POSITION } from 'common/constants';
2 | import { useDispatch, useSelector } from 'react-redux';
3 | import {
4 | selectDockPosition,
5 | setDockPosition,
6 | } from 'renderer/store/features/devtools';
7 | import Toggle from 'renderer/components/Toggle';
8 |
9 | const Devtools = () => {
10 | const dockPosition = useSelector(selectDockPosition);
11 | const dispatch = useDispatch();
12 |
13 | return (
14 |
15 |
Dock Devtools
16 |
17 | {
20 | if (value.target.checked) {
21 | dispatch(setDockPosition(DOCK_POSITION.BOTTOM));
22 | } else {
23 | dispatch(setDockPosition(DOCK_POSITION.UNDOCKED));
24 | }
25 | }}
26 | />
27 |
28 |
29 | );
30 | };
31 |
32 | export default Devtools;
33 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/ToolBar/Menu/Flyout/PreviewLayout/index.tsx:
--------------------------------------------------------------------------------
1 | import { Icon } from '@iconify/react';
2 | import { PREVIEW_LAYOUTS, PreviewLayout } from 'common/constants';
3 | import { useDispatch, useSelector } from 'react-redux';
4 | import { ButtonGroup } from 'renderer/components/ButtonGroup';
5 | import useKeyboardShortcut, {
6 | SHORTCUT_CHANNEL,
7 | } from 'renderer/components/KeyboardShortcutsManager/useKeyboardShortcut';
8 | import { selectLayout, setLayout } from 'renderer/store/features/renderer';
9 |
10 | const PreviewLayoutSelector = () => {
11 | const layout = useSelector(selectLayout);
12 | const dispatch = useDispatch();
13 |
14 | const handleLayout = (newLayout: PreviewLayout) => {
15 | dispatch(setLayout(newLayout));
16 | };
17 |
18 | const toggleNextLayout = () => {
19 | const layouts = Object.values(PREVIEW_LAYOUTS);
20 | const currentIndex = layouts.findIndex((l) => l === layout);
21 | const nextIndex = (currentIndex + 1) % layouts.length;
22 | dispatch(setLayout(layouts[nextIndex]));
23 | };
24 |
25 | useKeyboardShortcut(SHORTCUT_CHANNEL.PREVIEW_LAYOUT, toggleNextLayout);
26 |
27 | return (
28 |
29 |
Preview Layout
30 |
31 |
36 | {' '}
37 | Column
38 |
39 | ),
40 | srContent: 'Horizontal Layout',
41 | onClick: () => handleLayout(PREVIEW_LAYOUTS.COLUMN),
42 | isActive: layout === PREVIEW_LAYOUTS.COLUMN,
43 | },
44 | {
45 | content: (
46 |
47 | {' '}
48 | Flex
49 |
50 | ),
51 | srContent: 'Flex Layout',
52 | onClick: () => handleLayout(PREVIEW_LAYOUTS.FLEX),
53 | isActive: layout === PREVIEW_LAYOUTS.FLEX,
54 | },
55 | {
56 | content: (
57 |
58 | {' '}
59 | Masonry
60 |
61 | ),
62 | srContent: 'Masonry Layout',
63 | onClick: () => handleLayout(PREVIEW_LAYOUTS.MASONRY),
64 | isActive: layout === PREVIEW_LAYOUTS.MASONRY,
65 | },
66 | ]}
67 | />
68 |
69 |
70 | );
71 | };
72 |
73 | export default PreviewLayoutSelector;
74 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/ToolBar/Menu/Flyout/Settings/SettingsContent.test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { render, fireEvent } from '@testing-library/react';
4 |
5 | import { SettingsContent } from './SettingsContent';
6 |
7 | const mockOnClose = jest.fn();
8 |
9 | describe('SettingsContentHeader', () => {
10 | const renderComponent = () =>
11 | render( );
12 |
13 | it('Accept-Language is saved to store', () => {
14 | const { getByTestId } = renderComponent();
15 |
16 | const acceptLanguageInput = getByTestId('settings-accept_language-input');
17 | const screenshotLocationInput = getByTestId(
18 | 'settings-screenshot_location-input'
19 | );
20 | const saveButton = getByTestId('settings-save-button');
21 |
22 | fireEvent.change(acceptLanguageInput, { target: { value: 'cz-Cz' } });
23 | fireEvent.change(screenshotLocationInput, {
24 | target: { value: './path/location' },
25 | });
26 | fireEvent.click(saveButton);
27 |
28 | expect(window.electron.store.set).toHaveBeenNthCalledWith(
29 | 1,
30 | 'userPreferences.screenshot.saveLocation',
31 | './path/location'
32 | );
33 | expect(window.electron.store.set).toHaveBeenNthCalledWith(
34 | 2,
35 | 'userPreferences.customTitlebar',
36 | undefined
37 | );
38 | expect(window.electron.store.set).toHaveBeenNthCalledWith(
39 | 3,
40 | 'userPreferences.webRequestHeaderAcceptLanguage',
41 | 'cz-Cz'
42 | );
43 |
44 | expect(mockOnClose).toHaveBeenCalled();
45 | });
46 | });
47 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/ToolBar/Menu/Flyout/Settings/SettingsContentHeaders.tsx:
--------------------------------------------------------------------------------
1 | import { FC, useId } from 'react';
2 |
3 | interface ISettingsContentHeaders {
4 | acceptLanguage: string;
5 | setAcceptLanguage: (arg0: string) => void;
6 | }
7 |
8 | export const SettingsContentHeaders: FC = ({
9 | acceptLanguage = '',
10 | setAcceptLanguage,
11 | }) => {
12 | const id = useId();
13 |
14 | return (
15 | <>
16 | Request Headers
17 |
18 |
19 |
20 | Accept-Language
21 | setAcceptLanguage(e.target.value)}
29 | />
30 |
31 |
32 | HTTP request Accept-Language parameter (default: language from OS
33 | locale settings)
34 |
35 |
36 |
37 | >
38 | );
39 | };
40 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/ToolBar/Menu/Flyout/Settings/index.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import Button from 'renderer/components/Button';
3 | import Modal from 'renderer/components/Modal';
4 | import { SettingsContent } from './SettingsContent';
5 |
6 | interface Props {
7 | closeFlyout: () => void;
8 | }
9 |
10 | export const Settings = ({ closeFlyout }: Props) => {
11 | const [isOpen, setIsOpen] = useState(false);
12 |
13 | const onClose = () => setIsOpen(false);
14 |
15 | return (
16 |
17 | {
20 | setIsOpen(true);
21 | closeFlyout();
22 | }}
23 | >
24 | Settings
25 |
26 |
27 |
28 |
29 |
30 | );
31 | };
32 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/ToolBar/Menu/Flyout/UITheme/index.tsx:
--------------------------------------------------------------------------------
1 | import { Icon } from '@iconify/react';
2 | import { useDispatch, useSelector } from 'react-redux';
3 | import Button from 'renderer/components/Button';
4 | import useKeyboardShortcut, {
5 | SHORTCUT_CHANNEL,
6 | } from 'renderer/components/KeyboardShortcutsManager/useKeyboardShortcut';
7 | import { selectDarkMode, setDarkMode } from 'renderer/store/features/ui';
8 |
9 | const UITheme = () => {
10 | const darkMode = useSelector(selectDarkMode);
11 | const dispatch = useDispatch();
12 |
13 | const handleTheme = () => dispatch(setDarkMode(!darkMode));
14 |
15 | useKeyboardShortcut(SHORTCUT_CHANNEL.THEME, handleTheme);
16 |
17 | return (
18 |
19 |
UI Theme
20 |
21 |
22 |
23 |
24 |
25 |
26 | );
27 | };
28 |
29 | export default UITheme;
30 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/ToolBar/Menu/Flyout/Zoom.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback } from 'react';
2 | import { useDispatch, useSelector } from 'react-redux';
3 | import Button from 'renderer/components/Button';
4 | import useKeyboardShortcut, {
5 | SHORTCUT_CHANNEL,
6 | } from 'renderer/components/KeyboardShortcutsManager/useKeyboardShortcut';
7 | import {
8 | selectZoomFactor,
9 | zoomIn,
10 | zoomOut,
11 | } from 'renderer/store/features/renderer';
12 |
13 | interface ZoomButtonProps {
14 | children: React.ReactNode;
15 | onClick: () => void;
16 | }
17 |
18 | const ZoomButton = ({ children, onClick }: ZoomButtonProps) => {
19 | return (
20 |
21 | {children}
22 |
23 | );
24 | };
25 |
26 | const Zoom = () => {
27 | const zoomfactor = useSelector(selectZoomFactor);
28 | const dispatch = useDispatch();
29 |
30 | const onZoomIn = useCallback(() => {
31 | dispatch(zoomIn());
32 | }, [dispatch]);
33 |
34 | const onZoomOut = useCallback(() => {
35 | dispatch(zoomOut());
36 | }, [dispatch]);
37 |
38 | useKeyboardShortcut(SHORTCUT_CHANNEL.ZOOM_IN, onZoomIn);
39 | useKeyboardShortcut(SHORTCUT_CHANNEL.ZOOM_OUT, onZoomOut);
40 |
41 | return (
42 |
43 |
Zoom
44 |
45 | -
46 | {Math.ceil(zoomfactor * 100)}%
47 | +
48 |
49 |
50 | );
51 | };
52 |
53 | export default Zoom;
54 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/ToolBar/Menu/Flyout/index.tsx:
--------------------------------------------------------------------------------
1 | import Notifications from 'renderer/components/Notifications/Notifications';
2 | import { Divider } from 'renderer/components/Divider';
3 | import Devtools from './Devtools';
4 | import UITheme from './UITheme';
5 | import Zoom from './Zoom';
6 | import AllowInSecureSSL from './AllowInSecureSSL';
7 | import ClearHistory from './ClearHistory';
8 | import PreviewLayout from './PreviewLayout';
9 | import Bookmark from './Bookmark';
10 | import { Settings } from './Settings';
11 |
12 | interface Props {
13 | closeFlyout: () => void;
14 | }
15 |
16 | const MenuFlyout = ({ closeFlyout }: Props) => {
17 | return (
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | );
38 | };
39 |
40 | export default MenuFlyout;
41 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/ToolBar/Menu/index.tsx:
--------------------------------------------------------------------------------
1 | import { Icon } from '@iconify/react';
2 | import { useDetectClickOutside } from 'react-detect-click-outside';
3 | import Button from 'renderer/components/Button';
4 | import { useDispatch, useSelector } from 'react-redux';
5 | import { closeMenuFlyout, selectMenuFlyout } from 'renderer/store/features/ui';
6 | import { selectNotifications } from 'renderer/store/features/renderer';
7 | import useLocalStorage from 'renderer/components/useLocalStorage/useLocalStorage';
8 | import NotificationsBubble from 'renderer/components/Notifications/NotificationsBubble';
9 | import MenuFlyout from './Flyout';
10 |
11 | const Menu = () => {
12 | const dispatch = useDispatch();
13 | const isMenuFlyoutOpen = useSelector(selectMenuFlyout);
14 | const notifications = useSelector(selectNotifications);
15 |
16 | const [hasNewNotifications, setHasNewNotifications] = useLocalStorage(
17 | 'hasNewNotifications',
18 | true
19 | );
20 |
21 | const ref = useDetectClickOutside({
22 | onTriggered: () => {
23 | if (!isMenuFlyoutOpen) {
24 | return;
25 | }
26 | dispatch(closeMenuFlyout(false));
27 | },
28 | });
29 |
30 | const handleFlyout = () => {
31 | dispatch(closeMenuFlyout(!isMenuFlyoutOpen));
32 | setHasNewNotifications(false);
33 | };
34 |
35 | const onClose = () => {
36 | dispatch(closeMenuFlyout(false));
37 | };
38 |
39 | return (
40 |
41 |
42 |
43 | {notifications &&
44 | notifications?.length > 0 &&
45 | Boolean(hasNewNotifications) && }
46 |
47 |
48 |
49 |
50 |
51 | );
52 | };
53 |
54 | export default Menu;
55 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/ToolBar/NavigationControls.tsx:
--------------------------------------------------------------------------------
1 | import { Icon } from '@iconify/react';
2 | import { webViewPubSub } from 'renderer/lib/pubsub';
3 | import Button from '../Button';
4 | import useKeyboardShortcut, {
5 | SHORTCUT_CHANNEL,
6 | ShortcutChannel,
7 | } from '../KeyboardShortcutsManager/useKeyboardShortcut';
8 |
9 | export const NAVIGATION_EVENTS = {
10 | BACK: 'back',
11 | FORWARD: 'forward',
12 | RELOAD: 'reload',
13 | };
14 |
15 | interface NavigationItemProps {
16 | label: string;
17 | icon: string;
18 | action: () => void;
19 | }
20 |
21 | const NavigationButton = ({ label, icon, action }: NavigationItemProps) => {
22 | const shortcutName: ShortcutChannel = label.toUpperCase() as ShortcutChannel;
23 | useKeyboardShortcut(SHORTCUT_CHANNEL[shortcutName], action);
24 | return (
25 |
26 |
27 |
28 | );
29 | };
30 |
31 | const ITEMS: NavigationItemProps[] = [
32 | {
33 | label: 'Back',
34 | icon: 'ic:round-arrow-back',
35 | action: () => {
36 | webViewPubSub.publish(NAVIGATION_EVENTS.BACK);
37 | },
38 | },
39 | {
40 | label: 'Forward',
41 | icon: 'ic:round-arrow-forward',
42 | action: () => {
43 | webViewPubSub.publish(NAVIGATION_EVENTS.FORWARD);
44 | },
45 | },
46 | {
47 | label: 'Refresh',
48 | icon: 'ic:round-refresh',
49 | action: () => {
50 | webViewPubSub.publish(NAVIGATION_EVENTS.RELOAD);
51 | },
52 | },
53 | ];
54 |
55 | const NavigationControls = () => {
56 | return (
57 |
58 | {ITEMS.map((item) => (
59 | // eslint-disable-next-line react/jsx-props-no-spreading
60 |
61 | ))}
62 |
63 | );
64 | };
65 |
66 | export default NavigationControls;
67 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/ToolBar/PreviewSuiteSelector/index.tsx:
--------------------------------------------------------------------------------
1 | import { Icon } from '@iconify/react';
2 | import { useDispatch, useSelector } from 'react-redux';
3 | import { DropDown } from 'renderer/components/DropDown';
4 | import {
5 | selectActiveSuite,
6 | selectSuites,
7 | setActiveSuite,
8 | } from 'renderer/store/features/device-manager';
9 | import { APP_VIEWS, setAppView } from 'renderer/store/features/ui';
10 |
11 | export const PreviewSuiteSelector = () => {
12 | const dispatch = useDispatch();
13 | const suites = useSelector(selectSuites);
14 | const activeSuite = useSelector(selectActiveSuite);
15 | return (
16 | }
18 | options={[
19 | ...suites.map((suite) => ({
20 | label: (
21 |
22 | {suite.name}
23 | {suite.id === activeSuite.id ? : null}
24 |
25 | ),
26 | onClick: () => dispatch(setActiveSuite(suite.id)),
27 | })),
28 | {
29 | type: 'separator',
30 | },
31 | {
32 | label: (
33 |
34 | Manage Suites
35 |
36 | ),
37 | onClick: () => {
38 | dispatch(setAppView(APP_VIEWS.DEVICE_MANAGER));
39 | },
40 | },
41 | ]}
42 | />
43 | );
44 | };
45 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/ToolBar/Shortcuts/ShortcutsModal/ShortcutButton.tsx:
--------------------------------------------------------------------------------
1 | interface Props {
2 | text: string[];
3 | }
4 |
5 | const ShortcutButton = ({ text }: Props) => {
6 | const btnText = text[0].split('+');
7 | const btnTextLength = btnText.length - 1;
8 |
9 | const formatText = (value: string) => {
10 | if (value === 'mod') {
11 | if (navigator?.userAgent?.includes('Windows')) {
12 | return `Ctrl`;
13 | }
14 | return `⌘`;
15 | }
16 |
17 | if (value.length === 1) return value.toUpperCase();
18 | return value;
19 | };
20 | return (
21 |
22 | {btnText.map((value, i) => (
23 |
24 |
25 | {formatText(value)}
26 |
27 | {i < btnTextLength && + }
28 |
29 | ))}
30 |
31 | );
32 | };
33 |
34 | export default ShortcutButton;
35 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/ToolBar/Shortcuts/ShortcutsModal/ShortcutName.tsx:
--------------------------------------------------------------------------------
1 | interface Props {
2 | text: string;
3 | }
4 |
5 | const ShortcutName = ({ text }: Props) => {
6 | const formattedText = text.replace('_', ' ').toLowerCase();
7 | return {formattedText}
;
8 | };
9 |
10 | export default ShortcutName;
11 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/ToolBar/Shortcuts/ShortcutsModal/index.tsx:
--------------------------------------------------------------------------------
1 | import { SHORTCUT_KEYS } from 'renderer/components/KeyboardShortcutsManager/constants';
2 | import Modal from 'renderer/components/Modal';
3 | import Button from 'renderer/components/Button';
4 | import ShortcutName from './ShortcutName';
5 | import ShortcutButton from './ShortcutButton';
6 |
7 | interface Props {
8 | isOpen: boolean;
9 | onClose: () => void;
10 | }
11 |
12 | export const shortcutsList = [
13 | {
14 | id: 0,
15 | name: 'General Shortcuts',
16 | shortcuts: Object.entries(SHORTCUT_KEYS).splice(0, 7),
17 | },
18 | {
19 | id: 1,
20 | name: 'Previewer Shorcuts',
21 | shortcuts: Object.entries(SHORTCUT_KEYS).splice(7),
22 | },
23 | ];
24 |
25 | const ShortcutsModal = ({ isOpen, onClose }: Props) => {
26 | return (
27 | <>
28 |
29 |
30 | {Object.values(shortcutsList).map((category) => (
31 |
32 |
33 | {category.name}
34 |
35 | {category.shortcuts.map((value) => (
36 |
37 |
38 |
39 |
40 | ))}
41 |
42 | ))}
43 |
44 |
45 | Close
46 |
47 |
48 |
49 |
50 | >
51 | );
52 | };
53 |
54 | export default ShortcutsModal;
55 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/ToolBar/Shortcuts/index.tsx:
--------------------------------------------------------------------------------
1 | import { Icon } from '@iconify/react';
2 | import { useState } from 'react';
3 | import Button from 'renderer/components/Button';
4 | import ShortcutsModal from './ShortcutsModal';
5 |
6 | const Shortcuts = () => {
7 | const [isOpen, setIsOpen] = useState(false);
8 | const handleClose = () => setIsOpen(!isOpen);
9 |
10 | return (
11 | <>
12 |
13 |
14 |
15 |
16 |
17 |
18 | >
19 | );
20 | };
21 |
22 | export default Shortcuts;
23 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/components/useLocalStorage/useLocalStorage.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 |
3 | function useLocalStorage(key: string, initialValue?: T) {
4 | const [storedValue, setStoredValue] = useState(() => {
5 | try {
6 | const item = window.localStorage.getItem(key);
7 | return item ? (JSON.parse(item) as T) : undefined;
8 | } catch (error) {
9 | console.error('Error reading from localStorage', error);
10 | return undefined;
11 | }
12 | });
13 |
14 | useEffect(() => {
15 | if (storedValue === undefined && initialValue !== undefined) {
16 | setStoredValue(initialValue);
17 | window.localStorage.setItem(key, JSON.stringify(initialValue));
18 | }
19 | }, [initialValue, storedValue, key]);
20 |
21 | const setValue = (value: T | ((val: T | undefined) => T)) => {
22 | try {
23 | const valueToStore =
24 | value instanceof Function ? value(storedValue) : value;
25 | setStoredValue(valueToStore);
26 | window.localStorage.setItem(key, JSON.stringify(valueToStore));
27 | } catch (error) {
28 | console.error('Error setting localStorage', error);
29 | }
30 | };
31 |
32 | const removeValue = () => {
33 | try {
34 | window.localStorage.removeItem(key);
35 | setStoredValue(undefined);
36 | } catch (error) {
37 | console.error('Error removing from localStorage', error);
38 | }
39 | };
40 |
41 | return [storedValue, setValue, removeValue] as const;
42 | }
43 |
44 | export default useLocalStorage;
45 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/context/ThemeProvider/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import { useSelector } from 'react-redux';
3 | import { selectDarkMode } from 'renderer/store/features/ui';
4 |
5 | const ThemeProvider = ({ children }: { children: React.ReactNode }) => {
6 | const darkMode = useSelector(selectDarkMode);
7 |
8 | useEffect(() => {
9 | const body = document.querySelector('body');
10 | 'bg-slate-200 text-light-normal dark:bg-slate-800 dark:text-dark-normal'
11 | .split(' ')
12 | .forEach((className) => {
13 | body?.classList.add(className);
14 | });
15 | if (darkMode) {
16 | document.documentElement.classList.add('dark');
17 | } else {
18 | document.documentElement.classList.remove('dark');
19 | }
20 | }, [darkMode]);
21 |
22 | return {children}
;
23 | };
24 |
25 | export default ThemeProvider;
26 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/index.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 | Responsively App
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/index.tsx:
--------------------------------------------------------------------------------
1 | import { IPC_MAIN_CHANNELS } from 'common/constants';
2 | import { createRoot } from 'react-dom/client';
3 | import App from './AppContent';
4 |
5 | const container = document.getElementById('root')!;
6 | const root = createRoot(container);
7 |
8 | window.electron.ipcRenderer
9 | .invoke(IPC_MAIN_CHANNELS.APP_META, [])
10 | .then((arg: any) => {
11 | window.responsively = { webviewPreloadPath: arg.webviewPreloadPath };
12 | return root.render( );
13 | })
14 | .catch((err) => {
15 | // eslint-disable-next-line no-console
16 | console.error(err);
17 | });
18 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/lib/pubsub/index.test.ts:
--------------------------------------------------------------------------------
1 | import Bluebird from 'bluebird';
2 | import PubSub from '.';
3 |
4 | describe('PubSub', () => {
5 | it('should invoke subscribed callback', async () => {
6 | const pubsub = new PubSub();
7 | let invokedTest = false;
8 | pubsub.subscribe('test', () => {
9 | invokedTest = true;
10 | });
11 | await pubsub.publish('test');
12 | expect(invokedTest).toBe(true);
13 | });
14 |
15 | it('should handler async handlers', async () => {
16 | const pubsub = new PubSub();
17 | pubsub.subscribe('test', async () => {
18 | await Bluebird.delay(1000);
19 | return 'handler1';
20 | });
21 | pubsub.subscribe('test', async () => {
22 | await Bluebird.delay(2000);
23 | return 'handler2';
24 | });
25 |
26 | const results = await pubsub.publish('test');
27 | expect(results).toEqual([
28 | { result: 'handler1', error: null },
29 | { result: 'handler2', error: null },
30 | ]);
31 | });
32 |
33 | it('should sends args to the handler', async () => {
34 | const pubsub = new PubSub();
35 | pubsub.subscribe('test', (arg: number) => {
36 | return `test${arg}`;
37 | });
38 | const results = await pubsub.publish('test', 10);
39 | expect(results).toHaveLength(1);
40 | expect(results[0].result).toBe('test10');
41 | });
42 |
43 | it('should return error from the handler', async () => {
44 | const pubsub = new PubSub();
45 | pubsub.subscribe('test', () => {
46 | throw new Error('test');
47 | });
48 | const results = await pubsub.publish('test');
49 | expect(results).toHaveLength(1);
50 | expect(results[0].result).toBeNull();
51 | expect(results[0].error).not.toBeNull();
52 | });
53 | });
54 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/lib/pubsub/index.ts:
--------------------------------------------------------------------------------
1 | import Bluebird from 'bluebird';
2 |
3 | interface HandlerResult {
4 | result: any;
5 | error: any;
6 | }
7 |
8 | type Handler = ((...args: any) => void) | ((...args: any) => Promise);
9 |
10 | class PubSub {
11 | registry: { [key: string]: Handler[] };
12 |
13 | constructor() {
14 | this.registry = {};
15 | }
16 |
17 | subscribe = (topic: string, callback: Handler): void => {
18 | if (!this.registry[topic]) {
19 | this.registry[topic] = [];
20 | }
21 | this.registry[topic].push(callback);
22 | };
23 |
24 | unsubscribe = (topic: string, callback: Handler): void => {
25 | if (!this.registry[topic]) {
26 | return;
27 | }
28 | const index = this.registry[topic].indexOf(callback);
29 | if (index > -1) {
30 | this.registry[topic].splice(index, 1);
31 | }
32 | };
33 |
34 | publish = async (topic: string, ...args: any[]): Promise => {
35 | if (!this.registry[topic]) {
36 | return [];
37 | }
38 |
39 | return Bluebird.map(this.registry[topic], async (callback: Handler) => {
40 | try {
41 | return { result: await callback(...args), error: null };
42 | } catch (err) {
43 | return { result: null, error: err };
44 | }
45 | });
46 | };
47 | }
48 |
49 | export const webViewPubSub = new PubSub();
50 |
51 | export default PubSub;
52 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/preload.d.ts:
--------------------------------------------------------------------------------
1 | import type { Channels } from 'common/constants';
2 |
3 | declare global {
4 | interface Window {
5 | electron: {
6 | ipcRenderer: {
7 | sendMessage(channel: Channels, ...args: T[]): void;
8 | on(
9 | channel: string,
10 | func: (...args: T[]) => void
11 | ): (() => void) | undefined;
12 | once(channel: string, func: (...args: T[]) => void): void;
13 | invoke(channel: string, ...args: T[]): Promise;
14 | removeListener(channel: string, func: (...args: T[]) => void): void;
15 | removeAllListeners(channel: string): void;
16 | };
17 | store: {
18 | /* eslint-disable @typescript-eslint/no-explicit-any */
19 | get: (key: string) => any;
20 | set: (key: string, val: any) => void;
21 | };
22 | };
23 | responsively: {
24 | webviewPreloadPath: string;
25 | };
26 | }
27 | }
28 |
29 | export {};
30 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/store/features/bookmarks/index.ts:
--------------------------------------------------------------------------------
1 | import { createSlice } from '@reduxjs/toolkit';
2 | import type { PayloadAction } from '@reduxjs/toolkit';
3 | import { v4 as uuidv4 } from 'uuid';
4 | import type { RootState } from '../..';
5 |
6 | export interface IBookmarks {
7 | id?: string;
8 | name: string;
9 | address: string;
10 | }
11 | export interface BookmarksState {
12 | bookmarks: IBookmarks[];
13 | }
14 |
15 | const initialState: BookmarksState = {
16 | bookmarks: window.electron.store.get('bookmarks'),
17 | };
18 |
19 | export const bookmarksSlice = createSlice({
20 | name: 'bookmarks',
21 | initialState,
22 | reducers: {
23 | addBookmark: (state, action: PayloadAction) => {
24 | const bookmarks: IBookmarks[] = window.electron.store.get('bookmarks');
25 | if (action.payload.id) {
26 | const index = bookmarks.findIndex(
27 | (bookmark) => bookmark.id === action.payload.id
28 | );
29 | bookmarks[index] = action.payload;
30 | } else {
31 | const updatedPayload = {
32 | ...action.payload,
33 | id: uuidv4(),
34 | };
35 | bookmarks.push(updatedPayload);
36 | }
37 | state.bookmarks = bookmarks;
38 | window.electron.store.set('bookmarks', bookmarks);
39 | },
40 | removeBookmark: (state, action) => {
41 | const bookmarks = window.electron.store.get('bookmarks');
42 | const bookmarkIndex = state.bookmarks.findIndex(
43 | (bookmark) => bookmark.id === action.payload.id
44 | );
45 | if (bookmarkIndex === -1) {
46 | return;
47 | }
48 | bookmarks.splice(bookmarkIndex, 1);
49 | state.bookmarks = bookmarks;
50 | window.electron.store.set('bookmarks', bookmarks);
51 | },
52 | },
53 | });
54 |
55 | // Action creators are generated for each case reducer function
56 | export const { addBookmark, removeBookmark } = bookmarksSlice.actions;
57 |
58 | export const selectBookmarks = (state: RootState) => state.bookmarks.bookmarks;
59 |
60 | export default bookmarksSlice.reducer;
61 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/store/features/device-manager/utils.ts:
--------------------------------------------------------------------------------
1 | import { getDevicesMap } from 'common/deviceList';
2 | import type { PreviewSuites } from '.';
3 |
4 | export const sanitizeSuites = () => {
5 | const existingSuites: PreviewSuites = window.electron.store.get(
6 | 'deviceManager.previewSuites'
7 | );
8 | if (existingSuites == null || existingSuites.length === 0) {
9 | window.electron.store.set('deviceManager.previewSuites', [
10 | {
11 | id: 'default',
12 | name: 'Default',
13 | devices: ['10008', '10013', '10015'],
14 | },
15 | ]);
16 |
17 | return;
18 | }
19 |
20 | let dirty = false;
21 |
22 | existingSuites.forEach((suite) => {
23 | const availableDevices = suite.devices.filter(
24 | (id) => getDevicesMap()[id] != null
25 | );
26 | if (availableDevices.length !== suite.devices.length) {
27 | suite.devices = availableDevices;
28 | dirty = true;
29 | }
30 | });
31 |
32 | if (dirty) {
33 | window.electron.store.set('deviceManager.previewSuites', existingSuites);
34 | }
35 | };
36 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/store/features/devtools/index.ts:
--------------------------------------------------------------------------------
1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit';
2 | import { DOCK_POSITION } from 'common/constants';
3 | import type { RootState } from '../..';
4 |
5 | const defaultBounds = { x: 0, y: 0, width: 0, height: 0 };
6 |
7 | export type DockPosition = typeof DOCK_POSITION[keyof typeof DOCK_POSITION];
8 |
9 | export interface DevtoolsState {
10 | bounds: {
11 | x: number;
12 | y: number;
13 | width: number;
14 | height: number;
15 | };
16 | isOpen: boolean;
17 | dockPosition: DockPosition;
18 | webViewId: number;
19 | }
20 |
21 | const initialState: DevtoolsState = {
22 | bounds: defaultBounds,
23 | isOpen: false,
24 | dockPosition: window.electron.store.get('devtools.dockPosition'),
25 | webViewId: -1,
26 | };
27 |
28 | export const devtoolsSlice = createSlice({
29 | name: 'devtools',
30 | initialState,
31 | reducers: {
32 | setBounds: (state, action: PayloadAction) => {
33 | state.bounds = action.payload;
34 | },
35 | setDevtoolsOpen: (state, action: PayloadAction) => {
36 | if (state.dockPosition === DOCK_POSITION.UNDOCKED) {
37 | return;
38 | }
39 | state.isOpen = true;
40 | state.webViewId = action.payload;
41 | },
42 | setDevtoolsClose: (state) => {
43 | state.isOpen = false;
44 | state.webViewId = -1;
45 | },
46 | setDockPosition: (state, action: PayloadAction) => {
47 | window.electron.store.set('devtools.dockPosition', action.payload);
48 | state.dockPosition = action.payload;
49 | },
50 | },
51 | });
52 |
53 | // Action creators are generated for each case reducer function
54 | export const { setBounds, setDevtoolsOpen, setDevtoolsClose, setDockPosition } =
55 | devtoolsSlice.actions;
56 |
57 | export const selectIsDevtoolsOpen = (state: RootState) => state.devtools.isOpen;
58 |
59 | export const selectDevtoolsWebviewId = (state: RootState) =>
60 | state.devtools.webViewId;
61 |
62 | export const selectDockPosition = (state: RootState) =>
63 | state.devtools.dockPosition;
64 |
65 | export default devtoolsSlice.reducer;
66 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/store/features/ruler/index.ts:
--------------------------------------------------------------------------------
1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit';
2 | import type { RootState } from '../..';
3 |
4 | export interface Coordinates {
5 | deltaX: number;
6 | deltaY: number;
7 | innerHeight: number;
8 | innerWidth: number;
9 | }
10 |
11 | export type RulersState = {
12 | isRulerEnabled: boolean;
13 | rulerCoordinates: Coordinates;
14 | };
15 |
16 | export type ViewResolution = string;
17 |
18 | const initialState: { [key: ViewResolution]: RulersState } = {};
19 |
20 | export const rulerSlice = createSlice({
21 | name: 'rulers',
22 | initialState,
23 | reducers: {
24 | setRuler: (
25 | state,
26 | action: PayloadAction<{
27 | rulerState: RulersState;
28 | resolution: ViewResolution;
29 | }>
30 | ) => {
31 | state[action.payload.resolution] = action.payload.rulerState;
32 | },
33 | },
34 | });
35 |
36 | // Action creators are generated for each case reducer function
37 | export const { setRuler } = rulerSlice.actions;
38 |
39 | export const selectRuler =
40 | (state: RootState) =>
41 | (resolution: ViewResolution | undefined): RulersState | undefined => {
42 | if (resolution && state.rulers[resolution]) {
43 | return state.rulers[resolution];
44 | }
45 | return undefined;
46 | };
47 |
48 | export const selectRulerEnabled =
49 | (state: RootState) => (resolution: ViewResolution | undefined) => {
50 | if (resolution && state.rulers[resolution]) {
51 | return state.rulers[resolution].isRulerEnabled;
52 | }
53 | return false;
54 | };
55 |
56 | export default rulerSlice.reducer;
57 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/store/features/ui/index.ts:
--------------------------------------------------------------------------------
1 | import { createSlice } from '@reduxjs/toolkit';
2 | import type { PayloadAction } from '@reduxjs/toolkit';
3 | import type { RootState } from '../..';
4 |
5 | export const APP_VIEWS = {
6 | BROWSER: 'BROWSER',
7 | DEVICE_MANAGER: 'DEVICE_MANAGER',
8 | } as const;
9 |
10 | export type AppView = typeof APP_VIEWS[keyof typeof APP_VIEWS];
11 |
12 | export interface UIState {
13 | darkMode: boolean;
14 | appView: AppView;
15 | menuFlyout: boolean;
16 | }
17 |
18 | const initialState: UIState = {
19 | darkMode: window.electron.store.get('ui.darkMode'),
20 | appView: APP_VIEWS.BROWSER,
21 | menuFlyout: false,
22 | };
23 |
24 | export const uiSlice = createSlice({
25 | name: 'ui',
26 | initialState,
27 | reducers: {
28 | setDarkMode: (state, action: PayloadAction) => {
29 | state.darkMode = action.payload;
30 | window.electron.store.set('ui.darkMode', action.payload);
31 | },
32 | setAppView: (state, action: PayloadAction) => {
33 | state.appView = action.payload;
34 | },
35 | closeMenuFlyout: (state, action: PayloadAction) => {
36 | state.menuFlyout = action.payload;
37 | },
38 | },
39 | });
40 |
41 | // Action creators are generated for each case reducer function
42 | export const { setDarkMode, setAppView, closeMenuFlyout } = uiSlice.actions;
43 |
44 | export const selectDarkMode = (state: RootState) => state.ui.darkMode;
45 | export const selectAppView = (state: RootState) => state.ui.appView;
46 | export const selectMenuFlyout = (state: RootState) => state.ui.menuFlyout;
47 |
48 | export default uiSlice.reducer;
49 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/store/index.ts:
--------------------------------------------------------------------------------
1 | import { configureStore } from '@reduxjs/toolkit';
2 |
3 | import deviceManagerReducer from './features/device-manager';
4 | import devtoolsReducer from './features/devtools';
5 | import rendererReducer from './features/renderer';
6 | import rulersReducer from './features/ruler';
7 | import uiReducer from './features/ui';
8 | import bookmarkReducer from './features/bookmarks';
9 |
10 | export const store = configureStore({
11 | reducer: {
12 | renderer: rendererReducer,
13 | ui: uiReducer,
14 | deviceManager: deviceManagerReducer,
15 | devtools: devtoolsReducer,
16 | bookmarks: bookmarkReducer,
17 | rulers: rulersReducer,
18 | },
19 | });
20 |
21 | // Infer the `RootState` and `AppDispatch` types from the store itself
22 | export type RootState = ReturnType;
23 | // Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
24 | export type AppDispatch = typeof store.dispatch;
25 |
--------------------------------------------------------------------------------
/desktop-app/src/renderer/titlebar-styles.css:
--------------------------------------------------------------------------------
1 | /* CET - Custom Electron Titlebar styles */
2 | .cet-title {
3 | color: white;
4 | }
5 |
6 | .cet-menubar {
7 | color: white;
8 | }
9 |
--------------------------------------------------------------------------------
/desktop-app/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 |
3 | const defaultTheme = require('tailwindcss/defaultTheme');
4 | const colors = require('tailwindcss/colors');
5 | const typography = require('@tailwindcss/typography');
6 |
7 | module.exports = {
8 | content: ['./src/renderer/**/*.tsx'],
9 | darkMode: 'class',
10 | theme: {
11 | extend: {
12 | colors: {
13 | dark: {
14 | normal: colors.gray['300'],
15 | },
16 | light: {
17 | normal: colors.gray['700'],
18 | },
19 | },
20 | fontFamily: {
21 | sans: ['Lato', ...defaultTheme.fontFamily.sans],
22 | },
23 | maxHeight: (theme) => ({
24 | ...theme('spacing'),
25 | }),
26 | maxWidth: (theme) => ({
27 | ...theme('spacing'),
28 | }),
29 | minHeight: (theme) => ({
30 | ...theme('spacing'),
31 | }),
32 | minWidth: (theme) => ({
33 | ...theme('spacing'),
34 | }),
35 | },
36 | },
37 | plugins: [typography],
38 | };
39 |
--------------------------------------------------------------------------------
/desktop-app/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "incremental": true,
4 | "target": "es2021",
5 | "module": "commonjs",
6 | "lib": ["dom", "es2021"],
7 | "jsx": "react-jsx",
8 | "strict": true,
9 | "sourceMap": true,
10 | "baseUrl": "./src",
11 | "moduleResolution": "node",
12 | "esModuleInterop": true,
13 | "allowSyntheticDefaultImports": true,
14 | "resolveJsonModule": true,
15 | "allowJs": true,
16 | "outDir": ".erb/dll"
17 | },
18 | "exclude": ["test", "release/build", "release/app/dist", ".erb/dll"]
19 | }
--------------------------------------------------------------------------------
/dev.code-workspace:
--------------------------------------------------------------------------------
1 | {
2 | "folders": [
3 | {
4 | "path": "desktop-app"
5 | },
6 | {
7 | "path": "browser-extension"
8 | }
9 | ],
10 | "settings": {
11 | "files.associations": {
12 | ".babelrc": "jsonc",
13 | ".eslintrc": "jsonc",
14 | ".prettierrc": "jsonc",
15 | ".stylelintrc": "json",
16 | ".dockerignore": "ignore",
17 | ".eslintignore": "ignore",
18 | ".flowconfig": "ignore"
19 | },
20 | "javascript.validate.enable": false,
21 | "javascript.format.enable": false,
22 | "typescript.validate.enable": false,
23 | "typescript.format.enable": false
24 | }
25 | }
--------------------------------------------------------------------------------