├── src ├── react-app-env.d.ts ├── types │ ├── browser.ts │ ├── theme.ts │ ├── platform.ts │ ├── index.ts │ └── draw.ts ├── containers │ └── App.ts ├── components │ ├── Screen │ │ ├── index.ts │ │ ├── Screen.tsx │ │ ├── Actions │ │ │ ├── Settings.tsx │ │ │ └── Screenshot.tsx │ │ ├── Header.tsx │ │ ├── Dimensions.tsx │ │ └── Iframe.tsx │ ├── Sidebar │ │ ├── index.ts │ │ ├── Heading.tsx │ │ ├── Sidebar.tsx │ │ ├── Export.tsx │ │ └── ElementSelector.tsx │ ├── Scrollbars.tsx │ ├── Draw │ │ ├── hooks │ │ │ ├── useStageDrag.ts │ │ │ ├── useKeyboardShortcuts.ts │ │ │ ├── useElement.ts │ │ │ └── useDrawingTool.ts │ │ ├── utils │ │ │ └── stroke.ts │ │ ├── tools │ │ │ ├── index.ts │ │ │ ├── tool.ts │ │ │ ├── text.ts │ │ │ ├── pen.ts │ │ │ ├── circle.ts │ │ │ ├── rect.ts │ │ │ ├── arrow.ts │ │ │ └── ellipse.ts │ │ ├── Toolbar │ │ │ └── settings │ │ │ │ ├── Color.tsx │ │ │ │ ├── ColorPopover.tsx │ │ │ │ └── Stroke.tsx │ │ ├── Elements │ │ │ ├── Pen.tsx │ │ │ ├── Arrow.tsx │ │ │ ├── Image.tsx │ │ │ ├── Circle.tsx │ │ │ ├── Rect.tsx │ │ │ ├── Ellipse.tsx │ │ │ └── Element.tsx │ │ ├── Header │ │ │ ├── Navigation.tsx │ │ │ ├── index.tsx │ │ │ └── Download.tsx │ │ ├── Transformer.tsx │ │ ├── index.tsx │ │ ├── contexts │ │ │ └── StageProvider.tsx │ │ ├── Canvas.tsx │ │ └── DialogWrapper.tsx │ ├── ToggleButton.tsx │ ├── EmptyState.tsx │ ├── AppLogo.tsx │ ├── ScreenshotBlocker.tsx │ ├── LoadingScreen.tsx │ ├── AppBar │ │ ├── Screenshot.tsx │ │ ├── Tools.tsx │ │ ├── ScreenDirection.tsx │ │ ├── Zoom.tsx │ │ ├── ViewMode.tsx │ │ ├── AddressBar.tsx │ │ └── index.tsx │ ├── Tabs │ │ └── index.tsx │ ├── App.tsx │ ├── Screens.tsx │ ├── Notifications │ │ └── index.tsx │ ├── HelpDialog.tsx │ ├── Advertisement │ │ └── index.tsx │ ├── UserAgentDialog.tsx │ ├── TabDialog.tsx │ └── LocalWarning.tsx ├── saga │ ├── utils │ │ ├── wait.ts │ │ ├── screenCaptureRequest.ts │ │ ├── iframeChannel.ts │ │ ├── buildScreenshotScrolls.ts │ │ └── sendMessageToScreens.ts │ ├── appReset.ts │ ├── getDimensionNameForScreenDirection.ts │ ├── appExport.ts │ ├── iframeLoaded.ts │ ├── searchElement.ts │ ├── fillUserAgent.ts │ ├── zoomToFit.ts │ ├── onRefresh.ts │ ├── onSetTab.ts │ ├── mouseInspect.ts │ ├── index.ts │ ├── backgroundCommunications.ts │ ├── screenScroll.ts │ ├── appImport.ts │ ├── initialize.ts │ ├── autoSave.ts │ └── iframeCommunications.ts ├── background │ ├── injected │ │ ├── types.d.ts │ │ ├── refresh.ts │ │ ├── sendMessage.ts │ │ ├── onMessage.ts │ │ ├── dimensions.ts │ │ ├── syncScroll.ts │ │ ├── syncClick.ts │ │ ├── eventsManager.ts │ │ └── inspectElement.ts │ └── init.ts ├── utils │ ├── clamp.ts │ ├── getPrefixedMessage.ts │ ├── screen.ts │ ├── loadImage.ts │ ├── saveAs.ts │ ├── toZip.ts │ ├── validation.ts │ ├── onDomReady.ts │ ├── errorMessage.ts │ ├── defaultTabs.ts │ ├── scrollToElement.ts │ ├── onRefresh.ts │ ├── state.ts │ ├── frameStorage.ts │ ├── domPath.ts │ ├── url.ts │ ├── validateAppState.ts │ └── findElement.ts ├── hooks │ ├── useAppDispatch.ts │ └── useAppSelector.ts ├── reducers │ ├── index.ts │ ├── notifications.ts │ ├── screenshots.ts │ ├── runtime.ts │ └── layout.ts ├── platform │ ├── index.ts │ ├── firefox.ts │ ├── chrome.ts │ └── local.ts ├── store │ └── index.ts ├── index.tsx ├── theme.ts ├── data │ └── userAgents.ts └── __tests__ │ └── utils │ └── findElement.test.ts ├── screenshot.png ├── public ├── logo.png ├── post.jpg ├── favicon.ico ├── manifest.json └── index.html ├── art ├── prom-1400x560.psd ├── prom-440x280.psd └── prom-920x680.psd ├── .prettierrc ├── scripts ├── config.js ├── build.js ├── start.js └── applyWebpackConfig.js ├── webpack.config.js ├── .gitignore ├── tsconfig.json ├── .github ├── FUNDING.yml └── ISSUE_TEMPLATE │ └── bug_report.md ├── LICENSE.md ├── README.md └── package.json /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skmail/responsive-viewer/HEAD/screenshot.png -------------------------------------------------------------------------------- /src/types/browser.ts: -------------------------------------------------------------------------------- 1 | export type Tab = { 2 | id: number 3 | url: string 4 | } 5 | -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skmail/responsive-viewer/HEAD/public/logo.png -------------------------------------------------------------------------------- /public/post.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skmail/responsive-viewer/HEAD/public/post.jpg -------------------------------------------------------------------------------- /src/containers/App.ts: -------------------------------------------------------------------------------- 1 | import App from '../components/App' 2 | 3 | export default App 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skmail/responsive-viewer/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/components/Screen/index.ts: -------------------------------------------------------------------------------- 1 | import Screen from './Screen' 2 | 3 | export default Screen 4 | -------------------------------------------------------------------------------- /art/prom-1400x560.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skmail/responsive-viewer/HEAD/art/prom-1400x560.psd -------------------------------------------------------------------------------- /art/prom-440x280.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skmail/responsive-viewer/HEAD/art/prom-440x280.psd -------------------------------------------------------------------------------- /art/prom-920x680.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skmail/responsive-viewer/HEAD/art/prom-920x680.psd -------------------------------------------------------------------------------- /src/components/Sidebar/index.ts: -------------------------------------------------------------------------------- 1 | import Sidebar from './Sidebar' 2 | 3 | export default Sidebar 4 | -------------------------------------------------------------------------------- /src/saga/utils/wait.ts: -------------------------------------------------------------------------------- 1 | export const wait = (ms: number) => 2 | new Promise(resolve => setTimeout(resolve, ms)) 3 | -------------------------------------------------------------------------------- /src/components/Scrollbars.tsx: -------------------------------------------------------------------------------- 1 | import Scrollbars from 'react-custom-scrollbars-2' 2 | 3 | export default Scrollbars 4 | -------------------------------------------------------------------------------- /src/background/injected/types.d.ts: -------------------------------------------------------------------------------- 1 | export declare global { 2 | interface Window { 3 | screenId: string 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/clamp.ts: -------------------------------------------------------------------------------- 1 | export const clamp = (num: number, min: number, max: number) => 2 | Math.min(Math.max(num, min), max) 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/getPrefixedMessage.ts: -------------------------------------------------------------------------------- 1 | export const getPrefixedMessage = (message?: string) => 2 | `@RESPONSIVE-VIEWER/${message || ''}` 3 | -------------------------------------------------------------------------------- /src/utils/screen.ts: -------------------------------------------------------------------------------- 1 | export const getDomId = (id: string) => `screen-${id}` 2 | export const getIframeId = (id: string) => `screen-iframe-${id}` 3 | -------------------------------------------------------------------------------- /src/types/theme.ts: -------------------------------------------------------------------------------- 1 | import { Theme as MuiTheme } from '@mui/material/styles' 2 | export interface Theme extends MuiTheme { 3 | drawerWidth: number 4 | } 5 | -------------------------------------------------------------------------------- /src/hooks/useAppDispatch.ts: -------------------------------------------------------------------------------- 1 | import { useDispatch } from 'react-redux' 2 | import { AppDispatch } from '../store' 3 | 4 | export const useAppDispatch = () => useDispatch() 5 | -------------------------------------------------------------------------------- /src/hooks/useAppSelector.ts: -------------------------------------------------------------------------------- 1 | import { TypedUseSelectorHook, useSelector } from 'react-redux' 2 | import { RootState } from '../store' 3 | 4 | export const useAppSelector: TypedUseSelectorHook = useSelector 5 | -------------------------------------------------------------------------------- /src/components/Draw/hooks/useStageDrag.ts: -------------------------------------------------------------------------------- 1 | import Konva from 'konva' 2 | import { useRef } from 'react' 3 | 4 | export const useStageDrag = () => { 5 | const ref = useRef(null) 6 | 7 | return [ref] 8 | } 9 | -------------------------------------------------------------------------------- /scripts/config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | entry: { 3 | main: './src/index.tsx', 4 | inject: './src/background/injected/eventsManager.ts', 5 | background: './src/background/index.ts', 6 | init: './src/background/init.ts', 7 | }, 8 | } 9 | module.exports = config 10 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | 3 | module.exports = { 4 | plugins: [ 5 | new webpack.DefinePlugin({ 6 | 'process.env.REACT_APP_PLATFORM': JSON.stringify( 7 | process.env.REACT_APP_PLATFORM 8 | ), 9 | }), 10 | ], 11 | } 12 | -------------------------------------------------------------------------------- /src/components/Draw/utils/stroke.ts: -------------------------------------------------------------------------------- 1 | export const applyStrokeDashArray = (dashArray?: number[], strokeWidth = 0) => 2 | dashArray 3 | ? dashArray.map((dash, index) => { 4 | if ((index + 1) % 2 === 0) { 5 | return dash + strokeWidth 6 | } 7 | return dash 8 | }) 9 | : [] 10 | -------------------------------------------------------------------------------- /src/components/Draw/tools/index.ts: -------------------------------------------------------------------------------- 1 | import rect from './rect' 2 | import ellipse from './ellipse' 3 | import circle from './circle' 4 | import arrow from './arrow' 5 | import pen from './pen' 6 | import text from './text' 7 | 8 | export const tools = { 9 | rect, 10 | ellipse, 11 | circle, 12 | arrow, 13 | pen, 14 | text, 15 | } 16 | -------------------------------------------------------------------------------- /src/components/Sidebar/Heading.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react' 2 | import Typography from '@mui/material/Typography' 3 | 4 | interface Props { 5 | children: ReactNode 6 | } 7 | export const Heading = ({ children }: Props) => { 8 | return {children} 9 | } 10 | 11 | export default Heading 12 | -------------------------------------------------------------------------------- /src/utils/loadImage.ts: -------------------------------------------------------------------------------- 1 | export const loadImage = (url: string): Promise => { 2 | return new Promise((accept, reject) => { 3 | const image = new Image() 4 | image.onload = function() { 5 | accept(image) 6 | } 7 | image.onerror = function() { 8 | reject() 9 | } 10 | image.src = url 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /src/background/injected/refresh.ts: -------------------------------------------------------------------------------- 1 | import { onRefresh as onRefresh_ } from '../../utils/onRefresh' 2 | import { sendMessage } from './sendMessage' 3 | 4 | export const onRefresh = () => { 5 | onRefresh_(() => { 6 | sendMessage('REFRESH') 7 | 8 | window.location.reload() 9 | }) 10 | } 11 | 12 | export const refresh = () => { 13 | window.location.reload() 14 | } 15 | -------------------------------------------------------------------------------- /src/saga/appReset.ts: -------------------------------------------------------------------------------- 1 | import { call, put, takeLatest } from 'redux-saga/effects' 2 | import { initialize, appReset } from '../reducers/app' 3 | import { resetState } from '../utils/state' 4 | 5 | function* doAppReset() { 6 | yield call(resetState) 7 | 8 | yield put(initialize()) 9 | } 10 | 11 | export default function* rootSaga() { 12 | yield takeLatest(appReset.toString(), doAppReset) 13 | } 14 | -------------------------------------------------------------------------------- /src/saga/getDimensionNameForScreenDirection.ts: -------------------------------------------------------------------------------- 1 | import { ScreenDirection } from '../types' 2 | 3 | export const getDimensionNameForScreenDirection = ( 4 | screenDirection: ScreenDirection, 5 | field: 'width' | 'height' 6 | ) => { 7 | if (field === 'width') { 8 | return screenDirection === 'landscape' ? 'height' : 'width' 9 | } 10 | return screenDirection === 'landscape' ? 'width' : 'height' 11 | } 12 | -------------------------------------------------------------------------------- /src/reducers/index.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux' 2 | import app from './app' 3 | import layout from './layout' 4 | import runtime from './runtime' 5 | import notifications from './notifications' 6 | import screenshots from './screenshots' 7 | import draw from './draw' 8 | 9 | export default combineReducers({ 10 | app, 11 | layout, 12 | runtime, 13 | notifications, 14 | screenshots, 15 | draw, 16 | }) 17 | -------------------------------------------------------------------------------- /src/components/Draw/Toolbar/settings/Color.tsx: -------------------------------------------------------------------------------- 1 | import { SketchPicker } from 'react-color' 2 | import { styled } from '@mui/material/styles' 3 | 4 | export default styled(SketchPicker)(({ theme }) => ({ 5 | width: '100% !important', 6 | background: 'none !important', 7 | boxShadow: 'none !important', 8 | padding: '0 !important', 9 | '& label': { 10 | color: `${theme.palette.text.secondary} !important`, 11 | }, 12 | })) 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | .idea 25 | build.zip -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | const rewire = require('rewire') 2 | const applyWebpackConfig = require('./applyWebpackConfig') 3 | const defaults = rewire('react-scripts/scripts/build.js') 4 | let config = defaults.__get__('config') 5 | 6 | config.optimization.splitChunks = { 7 | cacheGroups: { 8 | default: false, 9 | }, 10 | } 11 | 12 | config.devtool = undefined 13 | 14 | config.optimization.runtimeChunk = false 15 | 16 | applyWebpackConfig(config) 17 | -------------------------------------------------------------------------------- /src/platform/index.ts: -------------------------------------------------------------------------------- 1 | import { Platform } from '../types/platform' 2 | import chrome from './chrome' 3 | import firefox from './firefox' 4 | import local from './local' 5 | 6 | let platform: Platform 7 | 8 | if (process.env.REACT_APP_PLATFORM === 'CHROME') { 9 | platform = chrome 10 | } else if (process.env.REACT_APP_PLATFORM === 'FIREFOX') { 11 | platform = firefox 12 | } else { 13 | platform = local 14 | } 15 | 16 | export default platform 17 | -------------------------------------------------------------------------------- /src/types/platform.ts: -------------------------------------------------------------------------------- 1 | export type Platform = { 2 | storage: { 3 | local: { 4 | get(key: string): Promise 5 | set(values: { [key: string]: any }): Promise 6 | remove: (key: string) => Promise 7 | } 8 | } 9 | runtime: { 10 | sendMessage: (...args: any[]) => void 11 | getURL: (...args: any[]) => string 12 | onMessage: { 13 | addListener: (...args: any) => void 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/background/injected/sendMessage.ts: -------------------------------------------------------------------------------- 1 | import { getPrefixedMessage } from '../../utils/getPrefixedMessage' 2 | 3 | export const sendMessage = ( 4 | message: string, 5 | data: { [key: string]: any } = {} 6 | ) => { 7 | if (!window.top) { 8 | return 9 | } 10 | window.top.postMessage( 11 | { 12 | ...data, 13 | message: getPrefixedMessage(message), 14 | screenId: window.screenId, 15 | }, 16 | '*' 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/saveAs.ts: -------------------------------------------------------------------------------- 1 | export const saveAs = (data: Blob | string, name: string) => { 2 | const link = document.createElement('a') 3 | link.innerText = 'Download' 4 | document.body.appendChild(link) 5 | link.download = name 6 | const isBlob = data instanceof Blob 7 | link.href = isBlob ? URL.createObjectURL(data) : data 8 | link.click() 9 | document.body.removeChild(link) 10 | if (isBlob) { 11 | URL.revokeObjectURL(link.href) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/toZip.ts: -------------------------------------------------------------------------------- 1 | import JSZip from 'jszip' 2 | import { imgSrcToBlob } from 'blob-util' 3 | import { ToZipInput } from '../types' 4 | 5 | export const toZip = async (files: ToZipInput) => { 6 | var zip = new JSZip() 7 | 8 | for (let file of files) { 9 | const image = await imgSrcToBlob(file.url) 10 | 11 | zip.file(file.filename, image) 12 | } 13 | 14 | const result = await zip.generateAsync({ type: 'blob' }) 15 | 16 | return result 17 | } 18 | -------------------------------------------------------------------------------- /src/components/Draw/tools/tool.ts: -------------------------------------------------------------------------------- 1 | import Konva from 'konva' 2 | import uuid from 'uuid' 3 | 4 | export default class Tool { 5 | stage: Konva.Stage 6 | layer: Konva.Layer 7 | constructor(stage: Konva.Stage) { 8 | this.stage = stage 9 | this.layer = stage.getLayers()[0] 10 | } 11 | move(data: any) {} 12 | finished() {} 13 | 14 | createDataElement(data = {}) { 15 | return { 16 | id: uuid.v4(), 17 | ...data, 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/validation.ts: -------------------------------------------------------------------------------- 1 | import isURL from 'validator/lib/isURL' 2 | 3 | export const url = (value: string) => { 4 | return isURL(value, { 5 | require_tld: false, 6 | require_protocol: false, 7 | }) 8 | } 9 | 10 | export const required = (value: string) => 11 | value && String(value).trim() !== '' ? undefined : 'Required' 12 | 13 | export const unique = (list: string[], exclude?: string) => (value: string) => { 14 | if (list.includes(value) && exclude !== value) { 15 | return false 16 | } 17 | return 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/onDomReady.ts: -------------------------------------------------------------------------------- 1 | const onDomReady = (callback: () => void) => { 2 | if (document && document.getElementsByTagName('body')[0]) { 3 | callback() 4 | return 5 | } 6 | 7 | if (callback && typeof callback === 'function') { 8 | if (document.readyState === 'complete') { 9 | callback() 10 | } else { 11 | window.addEventListener( 12 | 'onload', 13 | function() { 14 | callback() 15 | }, 16 | false 17 | ) 18 | } 19 | } 20 | } 21 | 22 | export default onDomReady 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "preserve" 17 | }, 18 | "include": ["src"] 19 | } 20 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit' 2 | import saga from '../saga' 3 | import createSagaMiddleware from 'redux-saga' 4 | 5 | import rootReducer from '../reducers' 6 | 7 | const sagaMiddleware = createSagaMiddleware() 8 | 9 | const store = configureStore({ 10 | reducer: rootReducer, 11 | middleware: [sagaMiddleware], 12 | }) 13 | 14 | sagaMiddleware.run(saga) 15 | 16 | export type RootState = ReturnType 17 | 18 | export type AppDispatch = typeof store.dispatch 19 | 20 | export default store 21 | -------------------------------------------------------------------------------- /src/utils/errorMessage.ts: -------------------------------------------------------------------------------- 1 | import { FieldError } from 'react-hook-form' 2 | 3 | export const errorMessage = (error: FieldError, attribute = 'This field') => { 4 | let errorText = '' 5 | switch (error.type) { 6 | case 'required': 7 | errorText = ':attribute is required' 8 | break 9 | case 'unique': 10 | errorText = ':attribute is already taken' 11 | break 12 | case 'url': 13 | errorText = ':attribute is not a valid url' 14 | break 15 | } 16 | 17 | return errorText.replace(':attribute', attribute) 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/defaultTabs.ts: -------------------------------------------------------------------------------- 1 | import devices from '../data/devices' 2 | import { Device } from '../types' 3 | 4 | export const defaultTabs = () => [ 5 | { 6 | name: 'default', 7 | screens: devices.reduce((acc: string[], device: Device) => { 8 | if (device.visible) { 9 | acc.push(device.id) 10 | } 11 | return acc 12 | }, []), 13 | }, 14 | { 15 | name: 'mobile', 16 | screens: [], 17 | }, 18 | { 19 | name: 'tablet', 20 | screens: [], 21 | }, 22 | { 23 | name: 'desktop', 24 | screens: [], 25 | }, 26 | ] 27 | -------------------------------------------------------------------------------- /src/utils/scrollToElement.ts: -------------------------------------------------------------------------------- 1 | import scrollIntoView from 'scroll-into-view' 2 | export const scrollToElement = ( 3 | element: HTMLElement, 4 | align = {}, 5 | settings = {} 6 | ) => 7 | new Promise(resolve => { 8 | scrollIntoView( 9 | element, 10 | { 11 | time: 220, 12 | align: { 13 | top: 0, 14 | left: 0, 15 | topOffset: 0, 16 | leftOffset: 0, 17 | ...align, 18 | }, 19 | ...settings, 20 | }, 21 | () => { 22 | resolve(true) 23 | } 24 | ) 25 | }) 26 | -------------------------------------------------------------------------------- /src/saga/appExport.ts: -------------------------------------------------------------------------------- 1 | import { call, select, takeLatest } from 'redux-saga/effects' 2 | import { exportApp, selectApp, State } from '../reducers/app' 3 | 4 | import { saveAs } from '../utils/saveAs' 5 | 6 | function* doExportApp() { 7 | const state: State = yield select(selectApp) 8 | 9 | const { url, ...toSave } = state 10 | 11 | const dataStr = 12 | 'data:text/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(toSave)) 13 | 14 | yield call(saveAs, dataStr, 'responsive-viewer.json') 15 | } 16 | 17 | export default function* rootSaga() { 18 | yield takeLatest(exportApp.toString(), doExportApp) 19 | } 20 | -------------------------------------------------------------------------------- /src/utils/onRefresh.ts: -------------------------------------------------------------------------------- 1 | export const onRefresh = (callback: () => void) => { 2 | const onRefresh = (e: KeyboardEvent) => { 3 | let isF5 = false 4 | let isR = false 5 | 6 | const code = e.code 7 | if (code === 'F5') { 8 | isF5 = true 9 | } 10 | 11 | if (code === 'KeyR') { 12 | isR = true 13 | } 14 | 15 | if (isF5 || ((e.ctrlKey || e.metaKey) && isR)) { 16 | e.preventDefault() 17 | callback() 18 | } 19 | } 20 | 21 | window.addEventListener('keydown', onRefresh) 22 | 23 | return () => { 24 | window.removeEventListener('keydown', onRefresh) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Responsive Viewer", 4 | "author": "Solaiman Kmail", 5 | "version": "1.0.21", 6 | "description": "Show multiple screens once, Responsive design tester", 7 | "icons": { 8 | "128": "logo.png" 9 | }, 10 | "browser_action": { 11 | "default_icon": "logo.png" 12 | }, 13 | "background": { 14 | "scripts": ["static/js/background.js"], 15 | "persistent": true 16 | }, 17 | "permissions": [ 18 | "storage", 19 | "tabs", 20 | "activeTab", 21 | "webRequest", 22 | "webNavigation", 23 | "webRequestBlocking" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /src/utils/state.ts: -------------------------------------------------------------------------------- 1 | import platform from '../platform' 2 | import { State } from '../reducers/app' 3 | 4 | const STORAGE_KEY = 'APP_STATE' 5 | 6 | export const loadState = async () => { 7 | try { 8 | const state = await platform.storage.local.get(STORAGE_KEY) 9 | if (!state) { 10 | return {} 11 | } 12 | return state 13 | } catch (e) { 14 | return {} 15 | } 16 | } 17 | 18 | export const saveState = async (state: State) => { 19 | await platform.storage.local.set({ 20 | [STORAGE_KEY]: state, 21 | }) 22 | } 23 | 24 | export const resetState = async () => { 25 | await platform.storage.local.remove(STORAGE_KEY) 26 | } 27 | -------------------------------------------------------------------------------- /src/background/injected/onMessage.ts: -------------------------------------------------------------------------------- 1 | import { getPrefixedMessage } from '../../utils/getPrefixedMessage' 2 | 3 | export const onMessage = (callback: (data: any) => void) => { 4 | const onMessage = (event: { data: any }) => { 5 | if ( 6 | !event.data || 7 | !String(event.data.message).startsWith(getPrefixedMessage()) 8 | ) { 9 | return 10 | } 11 | 12 | if (event.data.screenId === window.screenId) { 13 | return 14 | } 15 | 16 | callback(event.data) 17 | } 18 | 19 | window.addEventListener('message', onMessage) 20 | 21 | return () => { 22 | window.removeEventListener('message', onMessage) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/components/Draw/Elements/Pen.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Line as KonvaLine } from 'react-konva' 3 | import { PenElement } from '../../../types/draw' 4 | import { useElement } from '../hooks/useElement' 5 | 6 | interface Props { 7 | element: PenElement 8 | } 9 | 10 | const Pen = ({ element }: Props) => { 11 | const props = useElement(element) 12 | 13 | return ( 14 | 23 | ) 24 | } 25 | 26 | export default Pen 27 | -------------------------------------------------------------------------------- /src/components/Draw/Elements/Arrow.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Arrow as KonvaArrow } from 'react-konva' 3 | import { ArrowElement } from '../../../types/draw' 4 | import { useElement } from '../hooks/useElement' 5 | 6 | interface Props { 7 | element: ArrowElement 8 | } 9 | const Arrow = ({ element }: Props) => { 10 | const props = useElement(element) 11 | 12 | return ( 13 | 22 | ) 23 | } 24 | 25 | export default Arrow 26 | -------------------------------------------------------------------------------- /src/components/Screen/Screen.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo, memo } from 'react' 2 | import { getDomId } from '../../utils/screen' 3 | import Iframe from './Iframe' 4 | import Header from './Header' 5 | import { styled } from '@mui/material/styles' 6 | 7 | const Root = styled('div')(({ theme }) => ({ 8 | padding: theme.spacing(2), 9 | position: 'relative', 10 | })) 11 | 12 | interface Props { 13 | id: string 14 | } 15 | const Screen = ({ id }: Props) => { 16 | const domId = useMemo(() => getDomId(id), [id]) 17 | return ( 18 | 19 |
20 | 87 | 88 | {isAdsBlockerInstalled && ( 89 | 90 | 96 |
97 | Support ResponsiveViewer's free updates and quality. Please understand 98 | the importance of advertisements to keep ResponsiveViewer up to date. 99 |
100 | )} 101 | 102 | ) 103 | } 104 | 105 | export default Advertisement 106 | -------------------------------------------------------------------------------- /src/components/Sidebar/ElementSelector.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, MouseEvent } from 'react' 2 | import IconButton from '@mui/material/IconButton' 3 | import Popover from '@mui/material/Popover' 4 | import TextField from '@mui/material/TextField' 5 | import { useForm, SubmitHandler } from 'react-hook-form' 6 | import { useAppDispatch } from '../../hooks/useAppDispatch' 7 | import { searchElement } from '../../reducers/layout' 8 | import Tooltip from '@mui/material/Tooltip' 9 | 10 | const ElementInspect = ({ 11 | tooltipPlacement, 12 | }: { 13 | tooltipPlacement: 'right' | 'bottom' 14 | }) => { 15 | const dispatch = useAppDispatch() 16 | 17 | const { 18 | register, 19 | handleSubmit, 20 | formState: { isValid }, 21 | } = useForm({ 22 | mode: 'onChange', 23 | defaultValues: { 24 | selector: '', 25 | }, 26 | }) 27 | 28 | const [anchorEl, setAnchorEl] = useState(null) 29 | 30 | const handleClick = (event: MouseEvent) => { 31 | setAnchorEl(event.currentTarget as HTMLButtonElement) 32 | } 33 | 34 | const handleClose = () => { 35 | setAnchorEl(null) 36 | } 37 | 38 | const onSubmit: SubmitHandler<{ selector: string }> = values => { 39 | dispatch(searchElement(values.selector)) 40 | } 41 | 42 | const open = Boolean(anchorEl) 43 | const id = 'element-selector-popover' 44 | 45 | const searchIcon = ( 46 | 51 | 55 | 56 | ) 57 | 58 | return ( 59 | <> 60 | 61 | 62 | {searchIcon} 63 | 64 | 65 | 66 | 80 |
81 | 92 | {searchIcon} 93 | 94 | ), 95 | }} 96 | autoFocus 97 | /> 98 | 99 |
100 | 101 | ) 102 | } 103 | 104 | export default ElementInspect 105 | -------------------------------------------------------------------------------- /src/components/AppBar/AddressBar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import IconButton from '@mui/material/IconButton' 3 | import Input from '@mui/material/Input' 4 | import LinkIcon from '@mui/icons-material/Link' 5 | import RunIcon from '@mui/icons-material/PlayArrow' 6 | import RefreshIcon from '@mui/icons-material/Refresh' 7 | import * as validation from '../../utils/validation' 8 | import { useForm, SubmitHandler } from 'react-hook-form' 9 | import { useAppSelector } from '../../hooks/useAppSelector' 10 | import { selectUrl, updateUrl } from '../../reducers/app' 11 | import { refresh } from '../../reducers/layout' 12 | import { useAppDispatch } from '../../hooks/useAppDispatch' 13 | import { styled, alpha } from '@mui/material/styles' 14 | 15 | const Form = styled('form')(({ theme }) => ({ 16 | display: 'flex', 17 | alignItems: 'center', 18 | position: 'relative', 19 | height: 40, 20 | width: 350, 21 | [theme.breakpoints.down('md')]: { 22 | width: 250, 23 | }, 24 | })) 25 | 26 | const InputRightElement = styled(LinkIcon)(({ theme }) => ({ 27 | width: theme.spacing(3), 28 | height: theme.spacing(3), 29 | position: 'absolute', 30 | pointerEvents: 'none', 31 | display: 'flex', 32 | alignItems: 'center', 33 | justifyContent: 'center', 34 | left: theme.spacing(1.5), 35 | })) 36 | 37 | const InputField = styled(Input)(({ theme }) => ({ 38 | padding: theme.spacing(0, 6), 39 | height: '100%', 40 | width: '100%', 41 | borderRadius: 5, 42 | backgroundColor: alpha(theme.palette.common.white, 0.15), 43 | '&:hover': { 44 | backgroundColor: alpha(theme.palette.common.white, 0.25), 45 | }, 46 | '&:hover:not(.Mui-disabled):before': { 47 | borderBottom: 'none', 48 | }, 49 | '&:before': { 50 | borderBottom: 'none', 51 | }, 52 | })) 53 | 54 | const SubmitButton = styled(IconButton)(({ theme }) => ({ 55 | position: 'absolute', 56 | right: theme.spacing(1.5), 57 | })) 58 | 59 | const AddressBar = () => { 60 | const dispatch = useAppDispatch() 61 | 62 | const url = useAppSelector(selectUrl) 63 | 64 | const { register, handleSubmit, watch, setValue } = useForm({ 65 | mode: 'onChange', 66 | }) 67 | 68 | const formUrl = watch('url') 69 | 70 | useEffect(() => setValue('url', url), [url, setValue]) 71 | 72 | const onSubmit: SubmitHandler = values => { 73 | if (values.url === url) { 74 | dispatch(refresh()) 75 | } else { 76 | const url = values.url.replace(/^(?!(?:f|ht)tps?:\/\/)/, 'https://') 77 | 78 | dispatch(updateUrl(url)) 79 | } 80 | } 81 | 82 | return ( 83 |
84 | 85 | 86 | 97 | 98 | 104 | {formUrl === url && } 105 | {formUrl !== url && } 106 | 107 | 108 | ) 109 | } 110 | 111 | export default AddressBar 112 | -------------------------------------------------------------------------------- /src/background/injected/inspectElement.ts: -------------------------------------------------------------------------------- 1 | import throttle from 'lodash/throttle' 2 | import domPath from '../../utils/domPath' 3 | import findElement, { findWrappingSvg } from '../../utils/findElement' 4 | import { sendMessage } from './sendMessage' 5 | 6 | let highlightTimer: number 7 | 8 | export const clearInspector = () => { 9 | if (!highlightElement) { 10 | return 11 | } 12 | highlightElement.parentElement?.removeChild(highlightElement) 13 | } 14 | 15 | const onMouseLeave = () => { 16 | clearInspector() 17 | 18 | sendMessage('CLEAR_INSPECT_ELEMENT') 19 | } 20 | 21 | const onClick = (e: MouseEvent) => { 22 | sendMessage('FINISH_INSPECT_ELEMENT') 23 | } 24 | 25 | const getMouseElement = (e: MouseEvent) => { 26 | const element = document 27 | .elementsFromPoint(e.clientX, e.clientY) 28 | .find(element => element !== highlightElement) as HTMLElement 29 | 30 | const parentSvg = findWrappingSvg(element) 31 | 32 | return parentSvg ? parentSvg : element 33 | } 34 | 35 | let highlightElement: HTMLElement 36 | 37 | const renderHighlight = (rect: DOMRect) => { 38 | if (!highlightElement) { 39 | highlightElement = document.createElement('div') 40 | } 41 | 42 | highlightElement.style.width = withUnit(rect.width) 43 | highlightElement.style.height = withUnit(rect.height) 44 | highlightElement.style.top = withUnit(rect.top) 45 | highlightElement.style.left = withUnit(rect.left) 46 | highlightElement.style.outline = '2px dashed #FFC400' 47 | highlightElement.style.background = 'rgba(255, 196, 0,0.4)' 48 | highlightElement.style.position = 'fixed' 49 | highlightElement.style.zIndex = '9002' 50 | 51 | if (!highlightElement.parentElement) { 52 | document.body.appendChild(highlightElement) 53 | } 54 | 55 | return highlightElement 56 | } 57 | 58 | const withUnit = (value: number, unit = 'px') => `${value}${unit}` 59 | 60 | const inspectByMouseMove = throttle((e: MouseEvent) => { 61 | const element = getMouseElement(e) 62 | 63 | if (!element) { 64 | return 65 | } 66 | 67 | const rect = element.getBoundingClientRect() 68 | 69 | renderHighlight(rect) 70 | 71 | sendMessage('INSPECT_ELEMENT', { 72 | path: domPath(element), 73 | }) 74 | }, 200) 75 | 76 | function inViewport(rect: DOMRect) { 77 | return ( 78 | rect.top >= 0 && 79 | rect.left >= 0 && 80 | rect.bottom <= 81 | (window.innerHeight || document.documentElement.clientHeight) && 82 | rect.right <= (window.innerWidth || document.documentElement.clientWidth) 83 | ) 84 | } 85 | 86 | export const inspectByEvent = async (data: { path: string }) => { 87 | const element = findElement(data.path) as HTMLElement 88 | 89 | if (!element) { 90 | return 91 | } 92 | 93 | const rect = element.getBoundingClientRect() 94 | 95 | if (!inViewport(rect)) { 96 | window.scrollTo({ 97 | top: rect.y, 98 | left: rect.x, 99 | }) 100 | } 101 | 102 | if (highlightTimer) { 103 | clearTimeout(highlightTimer) 104 | } 105 | 106 | renderHighlight(rect) 107 | 108 | highlightTimer = window.setTimeout(clearInspector, 1500) 109 | } 110 | 111 | export const enableMouseInspector = () => { 112 | document.addEventListener('mousemove', inspectByMouseMove) 113 | 114 | document.addEventListener('mouseleave', onMouseLeave) 115 | 116 | document.addEventListener('click', onClick) 117 | } 118 | 119 | export const disableMouseInspector = () => { 120 | clearInspector() 121 | 122 | document.removeEventListener('mousemove', inspectByMouseMove) 123 | 124 | document.removeEventListener('mouseleave', onMouseLeave) 125 | 126 | document.removeEventListener('click', onClick) 127 | } 128 | -------------------------------------------------------------------------------- /src/components/UserAgentDialog.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react' 2 | import Dialog from '@mui/material/Dialog' 3 | import DialogContent from '@mui/material/DialogContent' 4 | import DialogTitle from '@mui/material/DialogTitle' 5 | import DialogActions from '@mui/material/DialogActions' 6 | import TextField from '@mui/material/TextField' 7 | import Button from '@mui/material/Button' 8 | import { useAppSelector } from '../hooks/useAppSelector' 9 | import { 10 | selectUserAgentDialog, 11 | toggleUserAgentDialog, 12 | } from '../reducers/layout' 13 | import { useForm, SubmitHandler } from 'react-hook-form' 14 | import { UserAgent } from '../types' 15 | import { saveUserAgent, selectUserAgents } from '../reducers/app' 16 | import * as validation from '../utils/validation' 17 | import { errorMessage } from '../utils/errorMessage' 18 | import { useAppDispatch } from '../hooks/useAppDispatch' 19 | 20 | const UserAgentDialog = () => { 21 | const userAgentDialog = useAppSelector(selectUserAgentDialog) 22 | const dispatch = useAppDispatch() 23 | 24 | const handleClose = () => { 25 | dispatch(toggleUserAgentDialog()) 26 | } 27 | 28 | const onSubmit: SubmitHandler = values => { 29 | dispatch(saveUserAgent(values)) 30 | handleClose() 31 | } 32 | 33 | const userAgents = useAppSelector(state => 34 | selectUserAgents(state).map(userAgent => userAgent.name) 35 | ) 36 | 37 | const uniqueUserAgent = useCallback( 38 | (value: string) => { 39 | return validation.unique(userAgents)(String(value).toLowerCase()) 40 | }, 41 | [userAgents] 42 | ) 43 | 44 | const { 45 | register, 46 | handleSubmit, 47 | formState: { errors }, 48 | } = useForm({ 49 | defaultValues: userAgentDialog.values, 50 | }) 51 | 52 | const id = userAgentDialog.open ? 'user-agent-form-dialog' : undefined 53 | 54 | return ( 55 |
56 | 57 |
58 | Add new user agent 59 | 60 | 61 | 79 | 80 | 96 | 97 | 98 | 99 | 100 | 103 | 104 |
105 |
106 |
107 | ) 108 | } 109 | 110 | export default UserAgentDialog 111 | -------------------------------------------------------------------------------- /src/components/Draw/DialogWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useState, 3 | useRef, 4 | useEffect, 5 | WheelEvent, 6 | MouseEvent as ReactMouseEvent, 7 | } from 'react' 8 | import Box from '@mui/material/Box' 9 | import { styled } from '@mui/material/styles' 10 | import Canvas from './Canvas' 11 | import { useAppSelector } from '../../hooks/useAppSelector' 12 | import { selectPan, selectSelectedPage } from '../../reducers/draw' 13 | 14 | const Root = styled(Box)(({ theme }) => ({ 15 | height: '100%', 16 | position: 'relative', 17 | padding: 0, 18 | display: 'flex', 19 | alignItems: 'center', 20 | justifyContent: 'center', 21 | overflow: 'hidden', 22 | })) 23 | 24 | const PanArea = styled(Box)(({ theme }) => ({ 25 | inset: 0, 26 | position: 'absolute', 27 | cursor: 'grab', 28 | })) 29 | 30 | const DialogWrapper = () => { 31 | const canvasRef = useRef() 32 | 33 | const [zoom, setZoom] = useState(1) 34 | const [translate, setTranslate] = useState({ x: 0, y: 0 }) 35 | 36 | const pan = useAppSelector(selectPan) 37 | const { pageWidth, pageHeight } = useAppSelector(state => { 38 | const page = selectSelectedPage(state) 39 | 40 | return { 41 | pageWidth: page?.width || 0, 42 | pageHeight: page?.height || 0, 43 | } 44 | }) 45 | const toFixed = (num: number) => parseFloat(num.toFixed(2)) 46 | useEffect(() => { 47 | if (!canvasRef.current) { 48 | return 49 | } 50 | const updateSize = () => { 51 | if (!canvasRef.current) { 52 | return 53 | } 54 | 55 | const containerRect = canvasRef.current.getBoundingClientRect() 56 | 57 | setZoom( 58 | toFixed( 59 | Math.min( 60 | Math.min( 61 | containerRect.width / pageWidth, 62 | containerRect.height / pageHeight 63 | ), 64 | 1 65 | ) 66 | ) 67 | ) 68 | 69 | setTranslate({ x: 0, y: 0 }) 70 | } 71 | updateSize() 72 | window.addEventListener('resize', updateSize) 73 | 74 | return () => { 75 | window.removeEventListener('resize', updateSize) 76 | } 77 | }, [pageWidth, pageHeight]) 78 | 79 | const onPan = (event: ReactMouseEvent) => { 80 | const initial = { 81 | x: event.pageX, 82 | y: event.pageY, 83 | } 84 | const onDrag = (event: MouseEvent) => { 85 | event.stopPropagation() 86 | event.preventDefault() 87 | const x = event.pageX - initial.x 88 | const y = event.pageY - initial.y 89 | initial.x = event.pageX 90 | initial.y = event.pageY 91 | setTranslate(translate => ({ 92 | x: translate.x + x, 93 | y: translate.y + y, 94 | })) 95 | } 96 | const onUp = () => { 97 | document.removeEventListener('mousemove', onDrag) 98 | document.removeEventListener('mouseup', onUp) 99 | } 100 | 101 | document.addEventListener('mousemove', onDrag) 102 | document.addEventListener('mouseup', onUp) 103 | } 104 | 105 | const onZoom = (event: WheelEvent) => { 106 | event.preventDefault() 107 | event.stopPropagation() 108 | const ZOOM_SENSITIVITY = 200 109 | const zoomAmount = -(event.deltaY / ZOOM_SENSITIVITY) 110 | setZoom(zoom => toFixed(Math.max(Math.min(zoom + zoomAmount, 2), 0.2))) 111 | } 112 | return ( 113 | 114 | 119 | 120 |
121 | 122 | {pan && } 123 | 124 | ) 125 | } 126 | 127 | export default DialogWrapper 128 | -------------------------------------------------------------------------------- /src/components/AppBar/index.tsx: -------------------------------------------------------------------------------- 1 | import MuiAppBar from '@mui/material/AppBar' 2 | import Button from '@mui/material/Button' 3 | import Stack from '@mui/material/Stack' 4 | import AddressBar from './AddressBar' 5 | import IconButton from '@mui/material/IconButton' 6 | import AddIcon from '@mui/icons-material/Add' 7 | import HelpIcon from '@mui/icons-material/Help' 8 | import TwitterIcon from '@mui/icons-material/Twitter' 9 | import GitHubIcon from '@mui/icons-material/GitHub' 10 | import CloseIcon from '@mui/icons-material/Close' 11 | 12 | import AppLogo from '../AppLogo' 13 | import { useAppDispatch } from '../../hooks/useAppDispatch' 14 | import { 15 | toggleDrawer, 16 | toggleHelpDialog, 17 | toggleScreenDialog, 18 | } from '../../reducers/layout' 19 | import { styled, lighten } from '@mui/material/styles' 20 | import Tools from './Tools' 21 | 22 | const AppBarView = styled(MuiAppBar)(({ theme }) => ({ 23 | borderBottom: `1px solid ${lighten(theme.palette.background.default, 0.2)} `, 24 | [theme.breakpoints.down('md')]: { 25 | minWidth: 750, 26 | }, 27 | })) 28 | const Logo = styled(AppLogo)(() => ({ 29 | width: 40, 30 | height: 'auto', 31 | flexShrink: 0, 32 | objectFit: 'contain', 33 | })) 34 | const CloseButton = styled(IconButton)(({ theme }) => ({ 35 | borderRadius: 0, 36 | margin: `${theme.spacing(-1, -1, -1, 1)} !important`, 37 | padding: theme.spacing(2), 38 | borderLeft: `1px solid ${lighten(theme.palette.background.default, 0.2)}`, 39 | backgroundColor: lighten(theme.palette.background.default, 0.05), 40 | })) 41 | 42 | const AppBar = () => { 43 | const dispatch = useAppDispatch() 44 | 45 | return ( 46 | 47 | 55 | 56 | dispatch(toggleDrawer())} 61 | /> 62 | 63 | 64 | 65 | 66 | 67 | 68 | 75 | 76 | 84 | 85 | 86 | 94 | 95 | 96 | 97 | dispatch(toggleHelpDialog())} 99 | edge="end" 100 | aria-label="Add Screen" 101 | aria-haspopup="true" 102 | color="inherit" 103 | > 104 | 105 | 106 | 107 | dispatch(toggleScreenDialog())} 109 | edge="end" 110 | aria-label="Add Screen" 111 | aria-haspopup="true" 112 | color="inherit" 113 | > 114 | 115 | 116 | 117 | {process.env.REACT_APP_PLATFORM !== 'LOCAL' && ( 118 | window.location.reload()} 120 | edge="end" 121 | aria-label="Add Screen" 122 | aria-haspopup="true" 123 | color="inherit" 124 | > 125 | 126 | 127 | )} 128 | 129 | 130 | 131 | ) 132 | } 133 | 134 | export default AppBar 135 | -------------------------------------------------------------------------------- /src/components/Screen/Iframe.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import Box, { BoxProps } from '@mui/material/Box' 3 | import LinearProgress from '@mui/material/LinearProgress' 4 | import { getIframeId } from '../../utils/screen' 5 | import { useAppSelector } from '../../hooks/useAppSelector' 6 | import { 7 | selectScreenById, 8 | selectScreenDirection, 9 | selectUrl, 10 | } from '../../reducers/app' 11 | import { selectHighlightedScreen } from '../../reducers/layout' 12 | import { styled } from '@mui/material/styles' 13 | import { shallowEqual } from 'react-redux' 14 | import { 15 | screenConnected, 16 | screenIsLoaded, 17 | selectIsScreenLoading, 18 | selectRuntimeFrameStatus, 19 | } from '../../reducers/runtime' 20 | import { FrameStatus } from '../../types' 21 | import { useAppDispatch } from '../../hooks/useAppDispatch' 22 | import { ResizeHandles } from './ResizeHandles' 23 | 24 | interface Props { 25 | id: string 26 | } 27 | 28 | interface RootProps extends BoxProps { 29 | isHighlighted: boolean 30 | } 31 | const Root = styled(({ isHighlighted, ...rest }: RootProps) => ( 32 | 33 | ))(({ theme, isHighlighted }) => ({ 34 | position: 'relative', 35 | transition: 'all ease 0.5s', 36 | width: 'fit-content', 37 | boxShadow: isHighlighted 38 | ? `0 0 0 4px ${theme.palette.primary.main}` 39 | : undefined, 40 | transform: isHighlighted ? 'scale(1.02)' : undefined, 41 | })) 42 | 43 | const IframeElement = styled('iframe')(() => ({ 44 | backgroundColor: '#fff', 45 | border: 'none', 46 | borderRadius: 2, 47 | display: 'block', 48 | })) 49 | 50 | const Progress = styled(LinearProgress)(() => ({ 51 | position: 'absolute', 52 | top: 0, 53 | width: '100%', 54 | })) 55 | 56 | const Iframe = ({ id }: Props) => { 57 | const dispatch = useAppDispatch() 58 | 59 | const screenDirection = useAppSelector(selectScreenDirection) 60 | const screen = useAppSelector( 61 | state => selectScreenById(state, id), 62 | shallowEqual 63 | ) 64 | 65 | const isLocal = process.env.REACT_APP_PLATFORM === 'LOCAL' 66 | 67 | const isHighlighted = useAppSelector( 68 | state => selectHighlightedScreen(state) === id 69 | ) 70 | const isLoading = useAppSelector(state => selectIsScreenLoading(state, id)) 71 | 72 | const [scrolling, setScrolling] = useState(true) 73 | 74 | const url = useAppSelector(state => { 75 | const frameStatus = selectRuntimeFrameStatus(state, id) 76 | if (frameStatus === FrameStatus.IDLE && !isLocal) { 77 | return `about:blank?screenId=${screen.id}` 78 | } 79 | return selectUrl(state) 80 | }) 81 | 82 | useEffect(() => { 83 | if (isLocal) { 84 | dispatch( 85 | screenConnected({ 86 | frameId: Math.random() * 100, 87 | screenId: screen.id, 88 | }) 89 | ) 90 | } 91 | let isShift = false 92 | 93 | const up = () => { 94 | if (!isShift) { 95 | return 96 | } 97 | isShift = false 98 | setScrolling(true) 99 | } 100 | 101 | const down = (e: KeyboardEvent) => { 102 | if (!e.shiftKey) { 103 | return 104 | } 105 | isShift = true 106 | setScrolling(false) 107 | } 108 | 109 | document.addEventListener('keyup', up) 110 | document.addEventListener('keydown', down) 111 | 112 | return () => { 113 | document.removeEventListener('keydown', down) 114 | document.removeEventListener('keyup', up) 115 | } 116 | }, [isLocal, screen.id, dispatch]) 117 | 118 | const width = screenDirection === 'landscape' ? screen.height : screen.width 119 | const height = screenDirection === 'landscape' ? screen.width : screen.height 120 | 121 | return ( 122 | 123 | {isLoading && } 124 | { 135 | if (isLocal) { 136 | dispatch(screenIsLoaded(screen.id)) 137 | } 138 | }} 139 | /> 140 | 141 | 142 | ) 143 | } 144 | 145 | export default Iframe 146 | -------------------------------------------------------------------------------- /src/components/TabDialog.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from 'react' 2 | import Dialog from '@mui/material/Dialog' 3 | import DialogContent from '@mui/material/DialogContent' 4 | import DialogTitle from '@mui/material/DialogTitle' 5 | import DialogActions from '@mui/material/DialogActions' 6 | import TextField from '@mui/material/TextField' 7 | import Button from '@mui/material/Button' 8 | import { shallowEqual } from 'react-redux' 9 | import { useAppSelector } from '../hooks/useAppSelector' 10 | import { useAppDispatch } from '../hooks/useAppDispatch' 11 | import { selectTabs, addTab, updateTab, deleteTab } from '../reducers/app' 12 | import { toggleTabDialog, selectTabDialog } from '../reducers/layout' 13 | import { useForm, SubmitHandler } from 'react-hook-form' 14 | import * as validation from '../utils/validation' 15 | import { errorMessage } from '../utils/errorMessage' 16 | 17 | const TabDialogForm = ({ 18 | values, 19 | }: { 20 | values: { 21 | name: string 22 | } 23 | }) => { 24 | const id = 'screen-dialog' 25 | const [isDeleteDialogOpened, setIsDeleteDialogOpened] = useState(false) 26 | 27 | const tabName = String(values.name || '') 28 | 29 | const dispatch = useAppDispatch() 30 | 31 | const isUpdate = Boolean(tabName) 32 | 33 | const { 34 | register, 35 | handleSubmit, 36 | formState: { errors }, 37 | } = useForm({ 38 | defaultValues: values, 39 | }) 40 | 41 | const handleClose = () => { 42 | dispatch(toggleTabDialog()) 43 | } 44 | 45 | const onSubmit: SubmitHandler<{ name: string }> = values => { 46 | if (isUpdate) { 47 | dispatch( 48 | updateTab({ 49 | name: tabName, 50 | tab: values, 51 | }) 52 | ) 53 | } else { 54 | dispatch(addTab(values)) 55 | } 56 | handleClose() 57 | } 58 | 59 | const onDelete = () => { 60 | dispatch(deleteTab(tabName)) 61 | setIsDeleteDialogOpened(false) 62 | handleClose() 63 | } 64 | 65 | const tabs = useAppSelector( 66 | state => selectTabs(state).map(tab => tab.name.toLowerCase()), 67 | shallowEqual 68 | ) 69 | 70 | const uniqueTabs = useCallback( 71 | (value: string) => { 72 | return validation.unique(tabs, tabName)(String(value).toLowerCase()) 73 | }, 74 | [tabName, tabs] 75 | ) 76 | 77 | const invalid = false 78 | 79 | return ( 80 | 81 |
82 | 83 | {isUpdate ? `Update ${tabName} tab` : 'Add new tab'} 84 | 85 | 86 | 102 | 103 | 104 | 105 | {isUpdate && ( 106 | 107 | 108 | Confirm tab deletion? 109 | 110 | 117 | 120 | 121 | 122 | 123 | 131 | 132 | )} 133 | 141 | 142 | 143 |
144 |
145 | ) 146 | } 147 | 148 | const TabDialog = () => { 149 | const tabDialog = useAppSelector(selectTabDialog) 150 | 151 | if (!tabDialog.open) { 152 | return null 153 | } 154 | 155 | return 156 | } 157 | 158 | export default TabDialog 159 | -------------------------------------------------------------------------------- /src/components/Draw/Toolbar/settings/Stroke.tsx: -------------------------------------------------------------------------------- 1 | import { useState, MouseEvent } from 'react' 2 | 3 | import MuiPopover from '@mui/material/Popover' 4 | import Button from '@mui/material/Button' 5 | import Slider from '@mui/material/Slider' 6 | import { styled, alpha } from '@mui/material/styles' 7 | import Color from './Color' 8 | import { applyStrokeDashArray } from '../../utils/stroke' 9 | import { useAppDispatch } from '../../../../hooks/useAppDispatch' 10 | import { useAppSelector } from '../../../../hooks/useAppSelector' 11 | import { selectSelectedElement, updateElement } from '../../../../reducers/draw' 12 | const width = 350 13 | 14 | const Popover = styled(MuiPopover)(({ theme }) => ({ 15 | '& .MuiPaper-root': { 16 | width, 17 | boxShadow: `0 0 0 2px ${theme.palette.primary.main}`, 18 | padding: theme.spacing(1), 19 | display: 'flex', 20 | }, 21 | })) 22 | 23 | const ColorPicker = styled('div')(({ theme }) => ({ 24 | flexShrink: 0, 25 | width: 170, 26 | margin: theme.spacing(-1, 1, -1, 0), 27 | padding: theme.spacing(1, 1, 1, 0), 28 | borderRight: `1px solid ${theme.palette.primary.main}`, 29 | })) 30 | 31 | const StyledDashIcon = styled('svg')(({ theme }) => ({ 32 | width: '100%', 33 | height: 10, 34 | })) 35 | 36 | const DashButton = styled(Button)(({ theme }) => ({ 37 | width: '100%', 38 | padding: theme.spacing(0.5, 1), 39 | color: alpha(theme.palette.text.secondary, 0.5), 40 | })) 41 | 42 | const dashes = [undefined, [2, 2], [4, 4], [9, 9], [29, 20, 0.001, 20]] 43 | 44 | const BoxIcon = ({ dashArray }: { dashArray?: number[] }) => { 45 | const strokeWidth = 3 46 | return ( 47 | 54 | 65 | 66 | ) 67 | } 68 | const Stroke = () => { 69 | const [anchorEl, setAnchorEl] = useState(null) 70 | const dispatch = useAppDispatch() 71 | const element = useAppSelector(selectSelectedElement) 72 | const handleClick = (event: MouseEvent) => { 73 | setAnchorEl(event.currentTarget as HTMLButtonElement) 74 | } 75 | 76 | const handleClose = () => { 77 | setAnchorEl(null) 78 | } 79 | 80 | const open = Boolean(anchorEl) 81 | 82 | const id = open ? 'simple-popover' : undefined 83 | 84 | return ( 85 | <> 86 | 89 | 99 | 100 | 102 | dispatch( 103 | updateElement({ 104 | id: element.id, 105 | props: { 106 | stroke: `rgba(${color.rgb.r},${color.rgb.g},${color.rgb.b}, ${color.rgb.a})`, 107 | }, 108 | }) 109 | ) 110 | } 111 | color={element.stroke} 112 | /> 113 | 114 | 115 |
116 |
117 | Stroke width 118 | { 120 | dispatch( 121 | updateElement({ 122 | id: element.id, 123 | props: { 124 | strokeWidth: Array.isArray(strokeWidth) 125 | ? strokeWidth[0] 126 | : strokeWidth, 127 | }, 128 | }) 129 | ) 130 | }} 131 | value={element.strokeWidth || 0} 132 | max={10} 133 | min={0} 134 | /> 135 |
136 | 137 |
138 | Dash 139 | {dashes.map((dash, index) => ( 140 | 143 | dispatch( 144 | updateElement({ 145 | id: element.id, 146 | props: { 147 | dash, 148 | }, 149 | }) 150 | ) 151 | } 152 | > 153 | 154 | 155 | ))} 156 |
157 |
158 |
159 | 160 | ) 161 | } 162 | 163 | export default Stroke 164 | -------------------------------------------------------------------------------- /src/components/LocalWarning.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import Dialog from '@mui/material/Dialog' 3 | import DialogContent from '@mui/material/DialogContent' 4 | import DialogContentText from '@mui/material/DialogContentText' 5 | import DialogTitle from '@mui/material/DialogTitle' 6 | import DialogActions from '@mui/material/DialogActions' 7 | import Button from '@mui/material/Button' 8 | import { styled, alpha, lighten } from '@mui/material/styles' 9 | import platform from '../platform' 10 | 11 | const ChromeBox = styled('a')(({ theme }) => ({ 12 | backgroundColor: theme.palette.background.paper, 13 | color: theme.palette.text.primary, 14 | borderRadius: 15, 15 | marginTop: theme.spacing(2), 16 | padding: theme.spacing(2), 17 | width: 'auto', 18 | display: 'inline-flex', 19 | alignItems: 'center', 20 | cursor: 'pointer', 21 | transition: 'all ease 0.2s', 22 | textDecoration: 'none', 23 | '&:hover': { 24 | backgroundColor: alpha(theme.palette.background.paper, 0.5), 25 | }, 26 | })) 27 | 28 | const ChromeIcon = styled('svg')(({ theme }) => ({ 29 | marginRight: theme.spacing(1), 30 | })) 31 | 32 | const ChromeHint = styled('div')(({ theme }) => ({ 33 | fontSize: 12, 34 | color: lighten(theme.palette.background.default, 0.5), 35 | })) 36 | 37 | const LocalWarning = () => { 38 | const isLocal = process.env.REACT_APP_PLATFORM === 'LOCAL' 39 | const [isClosedByLocalStorage, setIsClosedByLocalStorage] = useState(true) 40 | 41 | useEffect(() => { 42 | platform.storage.local.get('local-warning').then(value => { 43 | setIsClosedByLocalStorage(!!value) 44 | }) 45 | }, []) 46 | const [isOpened, setIsOpened] = useState(isLocal) 47 | 48 | const id = isOpened ? 'local-wraning-dialog' : undefined 49 | 50 | const onClose = () => { 51 | setIsOpened(false) 52 | platform.storage.local.set({ 53 | 'local-warning': true, 54 | }) 55 | } 56 | 57 | const extensionUrl = 58 | 'https://chrome.google.com/webstore/detail/responsive-viewer/inmopeiepgfljkpkidclfgbgbmfcennb?hl=en' 59 | 60 | return ( 61 |
62 | 67 | Limited functionality! 68 | 69 |
70 | 71 | The responsive viewer website is a preview mode only and has 72 | limited functionality, to unlock full capablities of the app, 73 | install the chrome extension, for free! 74 | 75 | 76 |
77 | 78 | 83 | 87 | 91 | 95 | 99 | 103 | 107 | 111 | 115 | 119 | 123 | 124 | 125 |
126 | Available on Chrome store 127 | Installed by 200,000+ users 128 |
129 |
130 |
131 | 132 | 133 | 134 |
135 |
136 |
137 |
138 | ) 139 | } 140 | 141 | export default LocalWarning 142 | -------------------------------------------------------------------------------- /src/reducers/layout.ts: -------------------------------------------------------------------------------- 1 | import { createAction, createSlice, PayloadAction } from '@reduxjs/toolkit' 2 | import { RootState } from '../store' 3 | import { Device, ScreensTab, UserAgent } from '../types' 4 | import { initialized } from './app' 5 | 6 | export type State = { 7 | initialized: boolean 8 | drawer: boolean 9 | inspectByMouse: boolean 10 | 11 | advertismentPosition: [number, number] 12 | tabDialog: { 13 | open: boolean 14 | values: Pick 15 | } 16 | screenDialog: { 17 | open: boolean 18 | values: Device 19 | } 20 | userAgentDialog: { 21 | open: boolean 22 | values: UserAgent 23 | } 24 | helpDialog: { 25 | open: boolean 26 | } 27 | highlightedScreen: string 28 | } 29 | const initialState: State = { 30 | initialized: false, 31 | drawer: true, 32 | inspectByMouse: false, 33 | advertismentPosition: [0, 0], 34 | 35 | highlightedScreen: '', 36 | tabDialog: { 37 | open: false, 38 | values: { 39 | name: '', 40 | }, 41 | }, 42 | screenDialog: { 43 | open: false, 44 | values: { 45 | id: '', 46 | name: '', 47 | width: 375, 48 | height: 812, 49 | userAgent: '', 50 | visible: true, 51 | }, 52 | }, 53 | userAgentDialog: { 54 | open: false, 55 | values: { 56 | name: '', 57 | value: '', 58 | }, 59 | }, 60 | helpDialog: { 61 | open: false, 62 | }, 63 | } 64 | export const slice = createSlice({ 65 | name: 'layout', 66 | initialState, 67 | reducers: { 68 | updateAdvertismentPosition(state, action: PayloadAction<[number, number]>) { 69 | state.advertismentPosition = action.payload 70 | }, 71 | toggleDrawer(state) { 72 | state.drawer = !state.drawer 73 | }, 74 | toggleMouseInspect(state, action: PayloadAction) { 75 | state.inspectByMouse = 76 | action.payload === undefined ? !state.inspectByMouse : action.payload 77 | }, 78 | 79 | highlightScreen(state, action: PayloadAction) { 80 | state.highlightedScreen = action.payload 81 | }, 82 | dehighlightScreen(state) { 83 | state.highlightedScreen = initialState.highlightedScreen 84 | }, 85 | 86 | toggleTabDialog( 87 | state, 88 | action: PayloadAction<{ name: string } | undefined> 89 | ) { 90 | if (state.tabDialog.open) { 91 | state.tabDialog = initialState.tabDialog 92 | } else { 93 | state.tabDialog = { 94 | open: true, 95 | values: action.payload || state.tabDialog.values, 96 | } 97 | } 98 | }, 99 | 100 | toggleUserAgentDialog(state) { 101 | if (state.userAgentDialog.open) { 102 | state.userAgentDialog = initialState.userAgentDialog 103 | } else { 104 | state.userAgentDialog = { 105 | open: true, 106 | values: initialState.userAgentDialog.values, 107 | } 108 | } 109 | }, 110 | 111 | updateScreenDialogValues(state, action: PayloadAction>) { 112 | state.screenDialog = { 113 | ...state.screenDialog, 114 | values: { 115 | ...state.screenDialog.values, 116 | ...action.payload, 117 | }, 118 | } 119 | }, 120 | 121 | toggleScreenDialog(state, action: PayloadAction) { 122 | if (state.screenDialog.open) { 123 | state.screenDialog = initialState.screenDialog 124 | } else { 125 | state.screenDialog = { 126 | open: true, 127 | values: action.payload || state.screenDialog.values, 128 | } 129 | } 130 | }, 131 | 132 | toggleHelpDialog(state) { 133 | if (state.helpDialog.open) { 134 | state.helpDialog = initialState.helpDialog 135 | } else { 136 | state.helpDialog = { 137 | ...state.helpDialog, 138 | open: true, 139 | } 140 | } 141 | }, 142 | }, 143 | extraReducers: { 144 | [initialized.toString()]: state => { 145 | state.initialized = true 146 | }, 147 | }, 148 | }) 149 | 150 | export const { 151 | toggleMouseInspect, 152 | highlightScreen, 153 | dehighlightScreen, 154 | 155 | toggleTabDialog, 156 | toggleUserAgentDialog, 157 | 158 | toggleScreenDialog, 159 | updateScreenDialogValues, 160 | 161 | toggleHelpDialog, 162 | toggleDrawer, 163 | updateAdvertismentPosition, 164 | } = slice.actions 165 | 166 | export const scrollToScreen = createAction('layout/scrollToScreen') 167 | export const searchElement = createAction('layout/searchElement') 168 | export const refresh = createAction('layout/refresh') 169 | 170 | export const zoomToFit = createAction('layout/zoomToFit') 171 | 172 | export const selectLayout = (state: RootState) => state.layout 173 | 174 | export const selectMouseInspect = (state: RootState) => 175 | selectLayout(state).inspectByMouse 176 | 177 | export const selectHighlightedScreen = (state: RootState) => 178 | selectLayout(state).highlightedScreen 179 | 180 | export const selectIsAppReady = (state: RootState) => 181 | selectLayout(state).initialized 182 | 183 | export const selectTabDialog = (state: RootState) => 184 | selectLayout(state).tabDialog 185 | 186 | export const selectScreenDialog = (state: RootState) => 187 | selectLayout(state).screenDialog 188 | 189 | export const selectUserAgentDialog = (state: RootState) => 190 | selectLayout(state).userAgentDialog 191 | 192 | export const selectHelpDialog = (state: RootState) => 193 | selectLayout(state).helpDialog 194 | 195 | export const selectDrawer = (state: RootState) => selectLayout(state).drawer 196 | export const selectAdvertismentPosition = (state: RootState) => 197 | selectLayout(state).advertismentPosition 198 | 199 | export default slice.reducer 200 | --------------------------------------------------------------------------------