├── .github
└── FUNDING.yml
├── .gitignore
├── .prettierrc
├── LICENSE.md
├── README.md
├── art
├── prom-1400x560.psd
├── prom-440x280.psd
└── prom-920x680.psd
├── package-lock.json
├── package.json
├── public
├── favicon.ico
├── index.html
├── logo.png
├── manifest.json
└── post.jpg
├── screenshot.png
├── scripts
├── applyWebpackConfig.js
├── build.js
├── config.js
└── start.js
├── src
├── __tests__
│ └── utils
│ │ └── findElement.test.ts
├── background
│ ├── index.ts
│ ├── init.ts
│ └── injected
│ │ ├── dimensions.ts
│ │ ├── eventsManager.ts
│ │ ├── inspectElement.ts
│ │ ├── onMessage.ts
│ │ ├── refresh.ts
│ │ ├── sendMessage.ts
│ │ ├── syncClick.ts
│ │ ├── syncScroll.ts
│ │ └── types.d.ts
├── components
│ ├── Advertisement
│ │ └── index.tsx
│ ├── App.tsx
│ ├── AppBar
│ │ ├── AddressBar.tsx
│ │ ├── ScreenDirection.tsx
│ │ ├── Screenshot.tsx
│ │ ├── Tools.tsx
│ │ ├── ViewMode.tsx
│ │ ├── Zoom.tsx
│ │ └── index.tsx
│ ├── AppLogo.tsx
│ ├── Draw
│ │ ├── Canvas.tsx
│ │ ├── DialogWrapper.tsx
│ │ ├── Elements
│ │ │ ├── Arrow.tsx
│ │ │ ├── Circle.tsx
│ │ │ ├── Element.tsx
│ │ │ ├── Ellipse.tsx
│ │ │ ├── Image.tsx
│ │ │ ├── Pen.tsx
│ │ │ ├── Rect.tsx
│ │ │ └── Text.tsx
│ │ ├── Header
│ │ │ ├── Download.tsx
│ │ │ ├── Navigation.tsx
│ │ │ └── index.tsx
│ │ ├── Toolbar
│ │ │ ├── index.tsx
│ │ │ └── settings
│ │ │ │ ├── Color.tsx
│ │ │ │ ├── ColorPopover.tsx
│ │ │ │ └── Stroke.tsx
│ │ ├── Transformer.tsx
│ │ ├── contexts
│ │ │ └── StageProvider.tsx
│ │ ├── hooks
│ │ │ ├── useDrawingTool.ts
│ │ │ ├── useElement.ts
│ │ │ ├── useKeyboardShortcuts.ts
│ │ │ └── useStageDrag.ts
│ │ ├── index.tsx
│ │ ├── tools
│ │ │ ├── arrow.ts
│ │ │ ├── circle.ts
│ │ │ ├── ellipse.ts
│ │ │ ├── index.ts
│ │ │ ├── pen.ts
│ │ │ ├── rect.ts
│ │ │ ├── text.ts
│ │ │ └── tool.ts
│ │ └── utils
│ │ │ └── stroke.ts
│ ├── EmptyState.tsx
│ ├── HelpDialog.tsx
│ ├── LoadingScreen.tsx
│ ├── LocalWarning.tsx
│ ├── Notifications
│ │ └── index.tsx
│ ├── Screen
│ │ ├── Actions
│ │ │ ├── Screenshot.tsx
│ │ │ └── Settings.tsx
│ │ ├── Dimensions.tsx
│ │ ├── Header.tsx
│ │ ├── Iframe.tsx
│ │ ├── ResizeHandles.tsx
│ │ ├── Screen.tsx
│ │ └── index.ts
│ ├── ScreenDialog.tsx
│ ├── Screens.tsx
│ ├── ScreenshotBlocker.tsx
│ ├── Scrollbars.tsx
│ ├── Sidebar
│ │ ├── ElementSelector.tsx
│ │ ├── Export.tsx
│ │ ├── Heading.tsx
│ │ ├── Screens.tsx
│ │ ├── Sidebar.tsx
│ │ ├── Toolbar.tsx
│ │ └── index.ts
│ ├── TabDialog.tsx
│ ├── Tabs
│ │ └── index.tsx
│ ├── ToggleButton.tsx
│ └── UserAgentDialog.tsx
├── containers
│ └── App.ts
├── data
│ ├── devices.ts
│ └── userAgents.ts
├── hooks
│ ├── useAppDispatch.ts
│ └── useAppSelector.ts
├── index.tsx
├── platform
│ ├── chrome.ts
│ ├── firefox.ts
│ ├── index.ts
│ └── local.ts
├── react-app-env.d.ts
├── reducers
│ ├── app.ts
│ ├── draw.ts
│ ├── index.ts
│ ├── layout.ts
│ ├── notifications.ts
│ ├── runtime.ts
│ └── screenshots.ts
├── saga
│ ├── appExport.ts
│ ├── appImport.ts
│ ├── appReset.ts
│ ├── autoSave.ts
│ ├── backgroundCommunications.ts
│ ├── fillUserAgent.ts
│ ├── getDimensionNameForScreenDirection.ts
│ ├── iframeCommunications.ts
│ ├── iframeLoaded.ts
│ ├── index.ts
│ ├── initialize.ts
│ ├── mouseInspect.ts
│ ├── onRefresh.ts
│ ├── onSetTab.ts
│ ├── screenScroll.ts
│ ├── screenshot.ts
│ ├── searchElement.ts
│ ├── utils
│ │ ├── buildScreenshotScrolls.ts
│ │ ├── iframeChannel.ts
│ │ ├── screenCaptureRequest.ts
│ │ ├── sendMessageToScreens.ts
│ │ └── wait.ts
│ └── zoomToFit.ts
├── store
│ └── index.ts
├── theme.ts
├── types
│ ├── browser.ts
│ ├── draw.ts
│ ├── index.ts
│ ├── platform.ts
│ └── theme.ts
└── utils
│ ├── clamp.ts
│ ├── defaultTabs.ts
│ ├── domPath.ts
│ ├── errorMessage.ts
│ ├── findElement.ts
│ ├── frameStorage.ts
│ ├── getPrefixedMessage.ts
│ ├── loadImage.ts
│ ├── onDomReady.ts
│ ├── onRefresh.ts
│ ├── saveAs.ts
│ ├── screen.ts
│ ├── scrollToElement.ts
│ ├── state.ts
│ ├── toZip.ts
│ ├── url.ts
│ ├── validateAppState.ts
│ └── validation.ts
├── tsconfig.json
└── webpack.config.js
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
4 | patreon: #skmail
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: skmail
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | otechie: # Replace with a single Otechie username
12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
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
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "es5",
3 | "tabWidth": 2,
4 | "semi": false,
5 | "singleQuote": true
6 | }
7 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Solaiman Kmail (skmail)
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Responsive Viewer Chrome extension
2 |
3 |
4 |
5 | [][chrome]
6 | [][chrome]
7 | [][chrome]
8 | [][website]
9 |
10 |
11 |
12 | 
13 |
14 | # manual extension build
15 |
16 | `npm install` & `npm run build`
17 |
18 | go to [chrome extensions](chrome://extensions/)
19 |
20 | from top right corner turn on developer mode
21 |
22 | click on `Load unpacked` and select `build` folder.
23 |
24 |
25 |
26 | [chrome]: https://chrome.google.com/webstore/detail/responsive-viewer/inmopeiepgfljkpkidclfgbgbmfcennb?hl=en
27 | [website]: https://responsiveviewer.org/
28 |
--------------------------------------------------------------------------------
/art/prom-1400x560.psd:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skmail/responsive-viewer/8ca0aff1e33104c93321862a2640685c5da59b14/art/prom-1400x560.psd
--------------------------------------------------------------------------------
/art/prom-440x280.psd:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skmail/responsive-viewer/8ca0aff1e33104c93321862a2640685c5da59b14/art/prom-440x280.psd
--------------------------------------------------------------------------------
/art/prom-920x680.psd:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skmail/responsive-viewer/8ca0aff1e33104c93321862a2640685c5da59b14/art/prom-920x680.psd
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "responsive-viewer",
3 | "version": "1.0.0",
4 | "private": true,
5 | "dependencies": {
6 | "@dnd-kit/core": "^3.1.1",
7 | "@dnd-kit/sortable": "^4.0.0",
8 | "@emotion/react": "^11.7.1",
9 | "@emotion/styled": "^11.6.0",
10 | "@hookform/resolvers": "^2.8.8",
11 | "@mui/icons-material": "^5.4.1",
12 | "@mui/material": "^5.4.1",
13 | "@reduxjs/toolkit": "^1.7.2",
14 | "array-move": "^2.1.0",
15 | "blob-util": "^2.0.2",
16 | "file-saver": "^2.0.5",
17 | "husky": "^3.1.0",
18 | "jszip": "^3.7.1",
19 | "konva": "^8.3.2",
20 | "lodash": "^4.17.21",
21 | "prettier": "^1.19.1",
22 | "pretty-quick": "^2.0.1",
23 | "react": "^17.0.2",
24 | "react-color": "^2.19.3",
25 | "react-custom-scrollbars-2": "^4.4.0",
26 | "react-dom": "^17.0.2",
27 | "react-frame-component": "^5.2.1",
28 | "react-hook-form": "^7.26.1",
29 | "react-konva": "^17.0.2-5",
30 | "react-redux": "^7.2.6",
31 | "react-scripts": "^5.0.0",
32 | "redux": "^4.1.2",
33 | "redux-saga": "^1.1.3",
34 | "rewire": "^6.0.0",
35 | "scroll-into-view": "^1.9.7",
36 | "source-map-explorer": "^2.5.2",
37 | "use-image": "^1.0.8",
38 | "uuid": "^3.3.3",
39 | "validator": "^13.7.0",
40 | "yup": "^0.32.11"
41 | },
42 | "scripts": {
43 | "start:local": "REACT_APP_PLATFORM=LOCAL react-scripts start",
44 | "start": "REACT_APP_PLATFORM=CHROME node ./scripts/start.js",
45 | "build": "REACT_APP_PLATFORM=CHROME node ./scripts/build.js",
46 | "build:local": "REACT_APP_PLATFORM=LOCAL node ./scripts/build.js",
47 | "build:manifest": "cp ./public/manifest.json ./build/manifest.json",
48 | "test": "react-scripts test",
49 | "eject": "react-scripts eject",
50 | "pretty": "prettier --check 'src/**/*.ts' --write",
51 | "analyze": "source-map-explorer 'build/static/js/*.js'"
52 | },
53 | "eslintConfig": {
54 | "extends": "react-app"
55 | },
56 | "browserslist": {
57 | "production": [
58 | ">0.2%",
59 | "not dead",
60 | "not op_mini all"
61 | ],
62 | "development": [
63 | "last 1 chrome version",
64 | "last 1 firefox version",
65 | "last 1 safari version"
66 | ]
67 | },
68 | "devDependencies": {
69 | "@types/chrome": "^0.0.178",
70 | "@types/file-saver": "^2.0.5",
71 | "@types/jest": "^27.4.0",
72 | "@types/node": "^17.0.16",
73 | "@types/react": "^17.0.39",
74 | "@types/react-color": "^3.0.6",
75 | "@types/react-dom": "^17.0.11",
76 | "@types/scroll-into-view": "^1.16.0",
77 | "@types/uuid": "^8.3.4",
78 | "@types/validator": "^13.7.1",
79 | "cross-env": "^6.0.3",
80 | "typescript": "^4.5.5",
81 | "webpack-cli": "^4.9.2"
82 | },
83 | "husky": {
84 | "hooks": {
85 | "pre-commit": "pretty-quick --staged"
86 | }
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skmail/responsive-viewer/8ca0aff1e33104c93321862a2640685c5da59b14/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
24 |
25 |
26 |
27 |
28 |
37 | Responsive Viewer
38 |
39 |
40 |
41 |
42 |
52 |
53 |
54 |
--------------------------------------------------------------------------------
/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skmail/responsive-viewer/8ca0aff1e33104c93321862a2640685c5da59b14/public/logo.png
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/public/post.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skmail/responsive-viewer/8ca0aff1e33104c93321862a2640685c5da59b14/public/post.jpg
--------------------------------------------------------------------------------
/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skmail/responsive-viewer/8ca0aff1e33104c93321862a2640685c5da59b14/screenshot.png
--------------------------------------------------------------------------------
/scripts/applyWebpackConfig.js:
--------------------------------------------------------------------------------
1 | const rewire = require('rewire')
2 | const paths = rewire('react-scripts/config/paths.js')
3 | const config = require('./config')
4 | const fs = require('fs-extra')
5 |
6 | function copyPublicFolder() {
7 | fs.copySync(paths.appPublic, paths.appBuild, {
8 | dereference: true,
9 | filter: file => file !== paths.appHtml,
10 | })
11 | }
12 |
13 | function findPlugin(plugins, name) {
14 | for (let plugin of plugins) {
15 | if (plugin.constructor.name === name) {
16 | return plugin
17 | }
18 | }
19 | }
20 |
21 | function applyWebpackConfig(webpackConfig) {
22 | webpackConfig.output.filename = `static/js/[name].js`
23 |
24 | const miniCssExtractPlugin = findPlugin(
25 | webpackConfig.plugins,
26 | 'MiniCssExtractPlugin'
27 | )
28 |
29 | if (miniCssExtractPlugin) {
30 | miniCssExtractPlugin.options.filename = `static/css/[name].css`
31 | }
32 |
33 | const htmlWebpackPlugin = findPlugin(
34 | webpackConfig.plugins,
35 | 'HtmlWebpackPlugin'
36 | )
37 |
38 | htmlWebpackPlugin.userOptions.chunks = ['main']
39 |
40 | copyPublicFolder()
41 |
42 | webpackConfig.entry = config.entry
43 | }
44 |
45 | module.exports = applyWebpackConfig
46 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/scripts/start.js:
--------------------------------------------------------------------------------
1 | const rewire = require('rewire')
2 | const applyWebpackConfig = require('./applyWebpackConfig')
3 |
4 | const defaults = rewire('react-scripts/scripts/start.js')
5 | let createDevServerConfig = defaults.__get__('createDevServerConfig')
6 | let configFactory = defaults.__get__('configFactory')
7 |
8 | defaults.__set__('configFactory', (...args) => {
9 | const webpackConfig = configFactory(...args)
10 |
11 | applyWebpackConfig(webpackConfig)
12 |
13 | return webpackConfig
14 | })
15 |
16 | defaults.__set__('createDevServerConfig', (...args) => {
17 | const devServerConfig = createDevServerConfig(...args)
18 | devServerConfig.devMiddleware.writeToDisk = true
19 | devServerConfig.hot = false
20 | return devServerConfig
21 | })
22 |
--------------------------------------------------------------------------------
/src/__tests__/utils/findElement.test.ts:
--------------------------------------------------------------------------------
1 | import findElement, { splitPath } from '../../utils/findElement'
2 | import domPath from '../../utils/domPath'
3 | const append = (element: HTMLElement, parent: HTMLElement) =>
4 | parent.appendChild(element)
5 |
6 | test('split dom path', () => {
7 | expect(splitPath('div#id.class:eq(1) .element')).toStrictEqual([
8 | 'div#id.class',
9 | 1,
10 | '.element',
11 | ])
12 | })
13 |
14 | test('split dom path 2', () => {
15 | const result = splitPath('div:eq(0) > header > div:eq(1) ')
16 |
17 | expect(result).toStrictEqual(['div', 0, ':scope > header > div', 1])
18 | })
19 |
20 | const createElement = (id?: string) => {
21 | const element = document.createElement('div')
22 | if (id) {
23 | element.id = id
24 | }
25 | return element
26 | }
27 | test('find', () => {
28 | const element = createElement('element')
29 | const element1 = createElement('element1')
30 | const element2 = createElement('element2')
31 |
32 | append(element1, element)
33 | append(element2, element)
34 |
35 | const element3 = createElement('element3')
36 |
37 | append(element3, element1)
38 | const element4 = createElement()
39 | append(element4, element)
40 |
41 | append(element, document.body)
42 |
43 | expect(findElement(domPath(element4))).toBe(element4)
44 | expect(findElement(domPath(element3))).toBe(element3)
45 | expect(findElement(domPath(element1))).toBe(element1)
46 | })
47 |
48 | test('splitPath', () => {
49 | expect(splitPath('div#__next > div > div:eq(10)')).toStrictEqual([
50 | 'div#__next > div > div',
51 | 10,
52 | ])
53 | //
54 | })
55 |
--------------------------------------------------------------------------------
/src/background/init.ts:
--------------------------------------------------------------------------------
1 | const init = () => {
2 | const children = document.documentElement.children
3 |
4 | for (let i = 0; i < children.length; i++) {
5 | const element = children[i]
6 | if (['head', 'body'].includes(element.tagName) && element.parentNode) {
7 | element.parentNode.removeChild(element)
8 | }
9 | }
10 |
11 | document.head.innerHTML = ''
12 | document.body.innerHTML = ''
13 |
14 | const removeAttributes = (element: HTMLElement) => {
15 | Object.values(element.attributes).forEach(attribute => {
16 | element.removeAttribute(attribute.name)
17 | })
18 | }
19 |
20 | removeAttributes(document.body)
21 | removeAttributes(document.documentElement)
22 |
23 | const appRoot = document.createElement('div')
24 |
25 | appRoot.id = 'RESPONSIVE-VIEWER-ROOT'
26 |
27 | document.body.appendChild(appRoot)
28 | }
29 |
30 | init()
31 |
32 | export default init
33 |
--------------------------------------------------------------------------------
/src/background/injected/dimensions.ts:
--------------------------------------------------------------------------------
1 | import { sendMessage } from './sendMessage'
2 | const getScrollHeight = () => {
3 | return Math.max(
4 | document.body.scrollHeight,
5 | document.documentElement.scrollHeight,
6 | document.body.offsetHeight,
7 | document.documentElement.offsetHeight,
8 | document.body.clientHeight,
9 | document.documentElement.clientHeight
10 | )
11 | }
12 |
13 | const getScrollWidth = () => {
14 | return Math.max(
15 | document.body.scrollWidth,
16 | document.documentElement.scrollWidth,
17 | document.body.offsetWidth,
18 | document.documentElement.offsetWidth,
19 | document.body.clientWidth,
20 | document.documentElement.clientWidth
21 | )
22 | }
23 | export default function dimensions(data = {}) {
24 | sendMessage('DIMENSIONS', {
25 | ...data,
26 | height: getScrollHeight(),
27 | width: getScrollWidth(),
28 | })
29 | }
30 |
--------------------------------------------------------------------------------
/src/background/injected/eventsManager.ts:
--------------------------------------------------------------------------------
1 | import onDomReady from '../../utils/onDomReady'
2 | import syncScroll from './syncScroll'
3 | import { onRefresh, refresh } from './refresh'
4 | import dimensions from './dimensions'
5 | import syncClick, { triggerClickEvent, triggerInputEvent } from './syncClick'
6 | import {
7 | clearInspector,
8 | disableMouseInspector,
9 | enableMouseInspector,
10 | inspectByEvent,
11 | } from './inspectElement'
12 | import { sendMessage } from './sendMessage'
13 | import { onMessage } from './onMessage'
14 | import { getPrefixedMessage } from '../../utils/getPrefixedMessage'
15 |
16 | onDomReady(() => {
17 | chrome.runtime.sendMessage(
18 | {
19 | message: getPrefixedMessage('GET_SCREEN_ID'),
20 | },
21 | response => {
22 | window.screenId = response.screenId
23 |
24 | syncClick()
25 | onRefresh()
26 |
27 | sendMessage('READY')
28 |
29 | onMessage(data => {
30 | switch (data.message) {
31 | case getPrefixedMessage('FRAME_SCROLL'):
32 | syncScroll(data)
33 | break
34 |
35 | case getPrefixedMessage('CLICK'):
36 | triggerClickEvent(data)
37 | break
38 |
39 | case getPrefixedMessage('INSPECT_ELEMENT'):
40 | inspectByEvent(data)
41 | break
42 |
43 | case getPrefixedMessage('FINISH_INSPECT_ELEMENT'):
44 | case getPrefixedMessage('CLEAR_INSPECT_ELEMENT'):
45 | clearInspector()
46 | break
47 |
48 | case getPrefixedMessage('ENABLE_MOUSE_INSPECTOR'):
49 | enableMouseInspector()
50 | break
51 |
52 | case getPrefixedMessage('DISABLE_MOUSE_INSPECTOR'):
53 | disableMouseInspector()
54 | break
55 |
56 | case getPrefixedMessage('DIMENSIONS'):
57 | dimensions(data)
58 | break
59 |
60 | case getPrefixedMessage('DELEGATE_EVENT'):
61 | triggerInputEvent(data)
62 | break
63 |
64 | case getPrefixedMessage('REFRESH'):
65 | refresh()
66 | break
67 |
68 | default:
69 | break
70 | }
71 | })
72 | }
73 | )
74 | })
75 |
--------------------------------------------------------------------------------
/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/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/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/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/background/injected/syncClick.ts:
--------------------------------------------------------------------------------
1 | import getDomPath from '../../utils/domPath'
2 | import findElement from '../../utils/findElement'
3 | import { sendMessage } from './sendMessage'
4 |
5 | const onClick = (e: MouseEvent) => {
6 | if (!e.isTrusted) {
7 | return true
8 | }
9 | const target = e.target as HTMLElement
10 |
11 | if (!target) {
12 | return
13 | }
14 |
15 | const path = getDomPath(target)
16 |
17 | sendMessage('CLICK', {
18 | path,
19 | })
20 | }
21 |
22 | const onInput = (e: Event) => {
23 | if (!e.isTrusted) {
24 | return
25 | }
26 |
27 | const target = e.target as HTMLInputElement
28 |
29 | if (!target) {
30 | return
31 | }
32 |
33 | const path = getDomPath(target)
34 |
35 | sendMessage('DELEGATE_EVENT', {
36 | path,
37 | value: target.value,
38 | })
39 | }
40 |
41 | export default function syncClick() {
42 | document.addEventListener('click', onClick)
43 |
44 | document.addEventListener('input', onInput)
45 | }
46 |
47 | export const triggerClickEvent = ({ path }: { path: string }) => {
48 | let element = findElement(path) as HTMLElement
49 |
50 | if (!element) {
51 | return
52 | }
53 |
54 | if (element.tagName.toLowerCase() === 'input') {
55 | const evt = new MouseEvent('focus', {
56 | view: window,
57 | bubbles: true,
58 | cancelable: false,
59 | })
60 | element.dispatchEvent(evt)
61 | } else {
62 | const evt = new MouseEvent('click', {
63 | bubbles: true,
64 | cancelable: false,
65 | view: window,
66 | })
67 | element.dispatchEvent(evt)
68 | }
69 | }
70 |
71 | export const triggerInputEvent = ({
72 | path,
73 | value,
74 | }: {
75 | path: string
76 | value: string
77 | }) => {
78 | const element = findElement(path) as HTMLInputElement
79 |
80 | if (!element) {
81 | return
82 | }
83 |
84 | const evt = new KeyboardEvent('input', {
85 | bubbles: true,
86 | cancelable: false,
87 | view: window,
88 | })
89 | element.dispatchEvent(evt)
90 | element.value = value
91 | }
92 |
--------------------------------------------------------------------------------
/src/background/injected/syncScroll.ts:
--------------------------------------------------------------------------------
1 | import getDomPath from '../../utils/domPath'
2 | import { sendMessage } from './sendMessage'
3 |
4 | declare global {
5 | interface Window {
6 | userScroll: boolean
7 | }
8 | interface Document {
9 | onmousewheel: () => void
10 | }
11 | }
12 |
13 | window.userScroll = false
14 | function mouseEvent() {
15 | window.userScroll = true
16 | }
17 |
18 | function disableScrollEvent() {
19 | window.userScroll = false
20 | }
21 |
22 | // https://stackoverflow.com/questions/7035896/detect-whether-scroll-event-was-created-by-user
23 | document.addEventListener('keydown', e => {
24 | const scrollKeys = ['Space', 'PageUp', 'PageDown', 'ArrowUp', 'ArrowDown']
25 | const withCtrlScrollKeys = ['Home', 'End']
26 | if (
27 | scrollKeys.includes(e.code) ||
28 | (e.ctrlKey && withCtrlScrollKeys.includes(e.code))
29 | ) {
30 | window.userScroll = true
31 | }
32 | })
33 |
34 | document.addEventListener('DOMMouseScroll', mouseEvent, false)
35 |
36 | document.addEventListener('click', disableScrollEvent)
37 |
38 | document.onmousewheel = mouseEvent
39 |
40 | window.addEventListener(
41 | 'scroll',
42 | e => {
43 | if (!e.target) {
44 | return
45 | }
46 | if (window.userScroll === false) {
47 | return
48 | }
49 | sendMessage('FRAME_SCROLL', {
50 | scrollTop: document.documentElement.scrollTop,
51 | scrollLeft: document.documentElement.scrollLeft,
52 | path: getDomPath(e.target as HTMLElement),
53 | })
54 | },
55 | false
56 | )
57 |
58 | export default function syncScroll(data: {
59 | scrollTop: number
60 | scrollLeft: number
61 | }) {
62 | window.userScroll = false
63 | window.scrollTo({
64 | top: data.scrollTop,
65 | left: data.scrollLeft,
66 | })
67 | }
68 |
--------------------------------------------------------------------------------
/src/background/injected/types.d.ts:
--------------------------------------------------------------------------------
1 | export declare global {
2 | interface Window {
3 | screenId: string
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/src/components/Advertisement/index.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Chip, styled } from '@mui/material'
2 | import React, { useEffect, useRef, useState } from 'react'
3 | import { useAppSelector } from '../../hooks/useAppSelector'
4 | import { selectAdvertismentPosition } from '../../reducers/layout'
5 |
6 | type Message = {
7 | data: {
8 | [key: string]: any
9 | message: string
10 | }
11 | }
12 |
13 | const AdBlockerMessage = styled('div')(({ theme }) => ({
14 | padding: theme.spacing(2),
15 | fontSize: 14,
16 | }))
17 | const Advertisement = ({ fixed }: { fixed?: boolean }) => {
18 | const iframeRef = useRef(null)
19 | const [isLoaded, setIsLoaded] = useState(false)
20 | const [isAdsLoaded, setIsAdsLoaded] = useState(false)
21 | const [isAdsBlockerInstalled, setIsAdBlockerInstalled] = useState(false)
22 | useEffect(() => {
23 | const onMessage = function(message: Message) {
24 | if (message.data.message !== '@ADS/LOADED') {
25 | return
26 | }
27 | setIsAdsLoaded(true)
28 |
29 | if (!iframeRef.current) {
30 | return
31 | }
32 |
33 | const height = Math.min(190, message.data.height)
34 |
35 | iframeRef.current.style.flexShrink = '0'
36 | iframeRef.current.style.width = '100%'
37 | iframeRef.current.style.height = `${height}px`
38 | }
39 |
40 | window.addEventListener('message', onMessage)
41 |
42 | return () => {
43 | window.removeEventListener('message', onMessage)
44 | }
45 | }, [])
46 |
47 | useEffect(() => {
48 | if (!isLoaded) {
49 | return
50 | }
51 | let timer = setTimeout(() => {
52 | setIsAdBlockerInstalled(!isAdsLoaded)
53 | }, 1500)
54 |
55 | return () => {
56 | clearTimeout(timer)
57 | }
58 | }, [isAdsLoaded, isLoaded])
59 | const advertisementPosition = useAppSelector(selectAdvertismentPosition)
60 | let sx = fixed
61 | ? {
62 | position: 'absolute',
63 | left: `clamp( 0px, ${advertisementPosition[0]}px, calc(100vw - 200px))`,
64 | bottom: `clamp(0px, ${advertisementPosition[1]}px, calc(100vh - 200px))`,
65 | zIndex: 100,
66 | width: 200,
67 | borderRadius: 2,
68 | overflow: 'hidden',
69 | background: 'rgb(33, 33, 33)',
70 | }
71 | : {}
72 | return (
73 |
74 |
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/App.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react'
2 | import AppBar from './AppBar'
3 | import Screens from './Screens'
4 | import Sidebar from './Sidebar'
5 | import ScreenDialog from './ScreenDialog'
6 | import UserAgentDialog from './UserAgentDialog'
7 | import HelpDialog from './HelpDialog'
8 | import Tabs from './Tabs'
9 | import TabDialog from './TabDialog'
10 | import Draw from './Draw'
11 | import { initialize } from '../reducers/app'
12 | import { useAppDispatch } from '../hooks/useAppDispatch'
13 | import { useAppSelector } from '../hooks/useAppSelector'
14 | import { selectDrawer, selectIsAppReady } from '../reducers/layout'
15 |
16 | import Box from '@mui/material/Box'
17 | import LoadingScreen from './LoadingScreen'
18 | import Notifications from './Notifications'
19 | import ScreenshotBlocker from './ScreenshotBlocker'
20 | import { styled } from '@mui/material/styles'
21 | import LocalWarning from './LocalWarning'
22 | import Advertisement from './Advertisement'
23 |
24 | const Root = styled('div')(({ theme }) => ({
25 | overflow: 'hidden',
26 | height: '100vh',
27 | width: '100vw',
28 | display: 'flex',
29 | flexDirection: 'column',
30 | }))
31 |
32 | function App() {
33 | const dispatch = useAppDispatch()
34 | const initialized = useAppSelector(selectIsAppReady)
35 |
36 | useEffect(() => {
37 | dispatch(initialize())
38 | }, [dispatch])
39 |
40 | const drawer = useAppSelector(selectDrawer)
41 |
42 | if (!initialized) {
43 | return
44 | }
45 |
46 | return (
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 | {!drawer && }
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 | )
71 | }
72 |
73 | export default App
74 |
--------------------------------------------------------------------------------
/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 |
108 | )
109 | }
110 |
111 | export default AddressBar
112 |
--------------------------------------------------------------------------------
/src/components/AppBar/ScreenDirection.tsx:
--------------------------------------------------------------------------------
1 | import React, { useMemo } from 'react'
2 | import IconButton from '@mui/material/IconButton'
3 | import { useAppSelector } from '../../hooks/useAppSelector'
4 | import {
5 | selectScreenDirection,
6 | switchScreenDirection,
7 | } from '../../reducers/app'
8 | import { useAppDispatch } from '../../hooks/useAppDispatch'
9 | import { ScreenDirection as ScreenDirectionEnum } from '../../types'
10 | import { styled } from '@mui/material/styles'
11 | import Tooltip from '@mui/material/Tooltip'
12 |
13 | const Icon = styled('svg')(({ theme }) => ({
14 | width: theme.spacing(3),
15 | height: theme.spacing(3),
16 | }))
17 |
18 | const ScreenDirection = () => {
19 | const dispatch = useAppDispatch()
20 | const screenDirection = useAppSelector(selectScreenDirection)
21 |
22 | const directions = useMemo(
23 | () => [
24 | {
25 | name: ScreenDirectionEnum.portrait,
26 | label: 'Portrait mode',
27 | icon: (
28 |
34 |
40 |
41 | ),
42 | },
43 | {
44 | name: ScreenDirectionEnum.landscape,
45 | label: 'Landscape mode',
46 | icon: (
47 |
53 |
60 |
61 | ),
62 | },
63 | ],
64 | []
65 | )
66 |
67 | const direction =
68 | screenDirection === ScreenDirectionEnum.landscape
69 | ? directions[0]
70 | : directions[1]
71 |
72 | return (
73 |
74 | dispatch(switchScreenDirection(direction.name))}
77 | >
78 | {direction.icon}
79 |
80 |
81 | )
82 | }
83 |
84 | export default ScreenDirection
85 |
--------------------------------------------------------------------------------
/src/components/AppBar/Screenshot.tsx:
--------------------------------------------------------------------------------
1 | import React, { MouseEvent, useState } from 'react'
2 | import CameraIcon from '@mui/icons-material/CameraAlt'
3 | import MenuItem from '@mui/material/MenuItem'
4 | import Menu from '@mui/material/Menu'
5 | import IconButton from '@mui/material/IconButton'
6 | import { useAppDispatch } from '../../hooks/useAppDispatch'
7 | import { ScreenshotType } from '../../types'
8 | import { screenshot } from '../../reducers/screenshots'
9 | import Tooltip from '@mui/material/Tooltip'
10 |
11 | const Screenshot = () => {
12 | const [anchorEl, setAnchorEl] = useState(null)
13 |
14 | const onClick = (event: MouseEvent) => {
15 | setAnchorEl(event.currentTarget as HTMLDivElement)
16 | }
17 |
18 | const dispatch = useAppDispatch()
19 |
20 | const handleMenuItemClick = (type: ScreenshotType) => {
21 | dispatch(screenshot({ screens: [], type }))
22 | setAnchorEl(null)
23 | }
24 |
25 | const handleClose = () => {
26 | setAnchorEl(null)
27 | }
28 |
29 | return (
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
57 |
58 | )
59 | }
60 |
61 | export default Screenshot
62 |
--------------------------------------------------------------------------------
/src/components/AppBar/Tools.tsx:
--------------------------------------------------------------------------------
1 | import { useState, MouseEvent, useEffect } from 'react'
2 | import Popover from '@mui/material/Popover'
3 | import Zoom from './Zoom'
4 | import Screenshot from './Screenshot'
5 | import ViewMode from './ViewMode'
6 | import ScreenDirection from './ScreenDirection'
7 | import Stack from '@mui/material/Stack'
8 | import SettingsIcon from '@mui/icons-material/Settings'
9 | import IconButton from '@mui/material/IconButton'
10 | import { useTheme } from '@mui/material/styles'
11 | import useMediaQuery from '@mui/material/useMediaQuery'
12 |
13 | const Tools = () => {
14 | const [anchorEl, setAnchorEl] = useState(null)
15 | const open = Boolean(anchorEl)
16 | const id = open ? 'tools-popover' : undefined
17 |
18 | const onOpen = (event: MouseEvent) => {
19 | setAnchorEl(event.currentTarget as HTMLButtonElement)
20 | }
21 |
22 | const theme = useTheme()
23 | const isSmall = useMediaQuery(theme.breakpoints.down('lg'))
24 |
25 | useEffect(() => {
26 | if (!isSmall) {
27 | setAnchorEl(null)
28 | }
29 | }, [isSmall])
30 |
31 | const tools = (
32 |
33 |
34 |
35 |
36 |
37 |
38 | )
39 |
40 | return (
41 | <>
42 | {!isSmall && tools}
43 |
44 | {isSmall && (
45 | <>
46 |
47 |
48 |
49 |
50 | setAnchorEl(null)}
63 | >
64 | {tools}
65 |
66 | >
67 | )}
68 | >
69 | )
70 | }
71 |
72 | export default Tools
73 |
--------------------------------------------------------------------------------
/src/components/AppBar/ViewMode.tsx:
--------------------------------------------------------------------------------
1 | import React, { useMemo } from 'react'
2 | import IconButton from '@mui/material/IconButton'
3 | import Tooltip from '@mui/material/Tooltip'
4 |
5 | import { useAppSelector } from '../../hooks/useAppSelector'
6 | import { selectViewMode, switchViewMode } from '../../reducers/app'
7 | import { useAppDispatch } from '../../hooks/useAppDispatch'
8 | import { ViewMode as ViewModeEnum } from '../../types'
9 | import { styled } from '@mui/material/styles'
10 |
11 | const Icon = styled('svg')(({ theme }) => ({
12 | width: theme.spacing(3),
13 | height: theme.spacing(3),
14 | }))
15 |
16 | const ViewMode = () => {
17 | const dispatch = useAppDispatch()
18 | const viewMode = useAppSelector(selectViewMode)
19 | const modes = useMemo(
20 | () => [
21 | {
22 | name: ViewModeEnum.vertical,
23 | label: 'Stack screens on grid',
24 | icon: (
25 |
31 |
35 |
36 | ),
37 | },
38 | {
39 | name: ViewModeEnum.horizontal,
40 | label: 'Stack screens horizontally',
41 | icon: (
42 |
48 |
52 |
53 | ),
54 | },
55 | ],
56 | []
57 | )
58 |
59 | const mode = viewMode === ViewModeEnum.vertical ? modes[1] : modes[0]
60 |
61 | return (
62 |
63 | dispatch(switchViewMode(mode.name))}
66 | >
67 | {mode.icon}
68 |
69 |
70 | )
71 | }
72 |
73 | export default ViewMode
74 |
--------------------------------------------------------------------------------
/src/components/AppBar/Zoom.tsx:
--------------------------------------------------------------------------------
1 | import React, { ReactElement } from 'react'
2 | import MuiSlider from '@mui/material/Slider'
3 | import IconButton from '@mui/material/IconButton'
4 | import ZoomInIcon from '@mui/icons-material/ZoomIn'
5 | import ZoomOutIcon from '@mui/icons-material/ZoomOut'
6 | import Tooltip from '@mui/material/Tooltip'
7 | import Box from '@mui/material/Box'
8 | import { useAppSelector } from '../../hooks/useAppSelector'
9 | import { selectZoom, updateZoom } from '../../reducers/app'
10 | import { useAppDispatch } from '../../hooks/useAppDispatch'
11 |
12 | import { styled } from '@mui/material/styles'
13 | import Button from '@mui/material/Button'
14 | import { zoomToFit } from '../../reducers/layout'
15 |
16 | const Slider = styled(MuiSlider)(({ theme }) => ({
17 | width: 200,
18 | '& .MuiSlider-markLabel': {
19 | fontSize: 12,
20 | },
21 | [theme.breakpoints.down('lg')]: {
22 | display: 'none',
23 | },
24 | }))
25 |
26 | function zoomText(value: number) {
27 | return `${parseInt(String(value * 100))}%`
28 | }
29 |
30 | function ValueLabelComponent({
31 | children,
32 | value,
33 | }: {
34 | children: ReactElement
35 | value: number
36 | }) {
37 | return (
38 |
39 | {children}
40 |
41 | )
42 | }
43 |
44 | const Zoom = () => {
45 | const value = useAppSelector(selectZoom)
46 | const dispatch = useAppDispatch()
47 |
48 | const onChange = (value: number) => {
49 | dispatch(updateZoom(value))
50 | }
51 |
52 | const maxZoom = 2
53 | const minZoom = 0.1
54 | const zoomStep = 0.1
55 |
56 | const canZoomIn = value >= maxZoom
57 | const canZoomOut = value <= minZoom
58 |
59 | const zoomIn = () => onChange(value + zoomStep)
60 | const zoomOut = () => onChange(value - zoomStep)
61 |
62 | return (
63 |
64 |
65 |
66 |
67 |
70 | onChange(Array.isArray(value) ? value[0] : value)
71 | }
72 | components={{
73 | ValueLabel: ValueLabelComponent,
74 | }}
75 | getAriaValueText={zoomText}
76 | valueLabelFormat={zoomText}
77 | step={zoomStep}
78 | min={minZoom}
79 | max={maxZoom}
80 | valueLabelDisplay="auto"
81 | marks={[
82 | {
83 | value: 1,
84 | },
85 | {
86 | value: 0.5,
87 | },
88 | {
89 | value: 1.5,
90 | },
91 | ]}
92 | />
93 |
94 |
95 |
96 |
97 |
98 | )
99 | }
100 |
101 | export default Zoom
102 |
--------------------------------------------------------------------------------
/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/AppLogo.tsx:
--------------------------------------------------------------------------------
1 | import React, { SVGAttributes } from 'react'
2 |
3 | const AppLogo = (props: SVGAttributes) => {
4 | return (
5 |
42 | )
43 | }
44 |
45 | export default AppLogo
46 |
--------------------------------------------------------------------------------
/src/components/Draw/Canvas.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Stage, Layer } from 'react-konva'
3 | import Element from './Elements/Element'
4 | import { useStageDrag } from './hooks/useStageDrag'
5 | import { useDrawingTool } from './hooks/useDrawingTool'
6 |
7 | import { StageProvider } from './contexts/StageProvider'
8 | import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
9 |
10 | import { useAppSelector } from '../../hooks/useAppSelector'
11 | import { selectPageElementIds } from '../../reducers/draw'
12 | import { Provider } from 'react-redux'
13 | import store from '../../store'
14 | import Transformer from './Transformer'
15 |
16 | interface Props {
17 | width: number
18 | height: number
19 | zoom?: number
20 | }
21 | const Canvas = ({ width, height, zoom = 1 }: Props) => {
22 | const [stageRef] = useStageDrag()
23 |
24 | useDrawingTool(stageRef, zoom)
25 |
26 | useKeyboardShortcuts()
27 |
28 | const elementIds = useAppSelector(selectPageElementIds)
29 |
30 | return (
31 |
32 |
33 |
34 |
35 | {elementIds.map(id => (
36 |
37 | ))}
38 |
39 |
40 |
41 |
42 |
43 | )
44 | }
45 |
46 | export default Canvas
47 |
--------------------------------------------------------------------------------
/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/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/Draw/Elements/Circle.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Circle as KonvaCircle } from 'react-konva'
3 | import { CircleElement } from '../../../types/draw'
4 | import { useElement } from '../hooks/useElement'
5 | interface Props {
6 | element: CircleElement
7 | }
8 | const Circle = ({ element }: Props) => {
9 | const props = useElement(element)
10 | return (
11 |
22 | )
23 | }
24 |
25 | export default Circle
26 |
--------------------------------------------------------------------------------
/src/components/Draw/Elements/Element.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import image from './Image'
3 | import rect from './Rect'
4 | import ellipse from './Ellipse'
5 | import circle from './Circle'
6 | import arrow from './Arrow'
7 | import pen from './Pen'
8 | import text from './Text'
9 | import { useAppSelector } from '../../../hooks/useAppSelector'
10 | import { selectElementById } from '../../../reducers/draw'
11 | const types = {
12 | image,
13 | rect,
14 | ellipse,
15 | circle,
16 | arrow,
17 | pen,
18 | text,
19 | }
20 | interface Props {
21 | id: string
22 | }
23 |
24 | const Element = ({ id }: Props) => {
25 | const element = useAppSelector(state => selectElementById(state, id))
26 |
27 | if (!element) {
28 | return null
29 | }
30 |
31 | const ElementType = types[element.type]
32 |
33 | if (!ElementType) {
34 | return null
35 | }
36 | // TODO - handle ts-ignore
37 | // @ts-ignore
38 | return
39 | }
40 |
41 | export default Element
42 |
--------------------------------------------------------------------------------
/src/components/Draw/Elements/Ellipse.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Ellipse as KonvaEllipse } from 'react-konva'
3 | import { useElement } from '../hooks/useElement'
4 | import { EllipseElement } from '../../../types/draw'
5 | interface Props {
6 | element: EllipseElement
7 | }
8 | const Ellipse = ({ element }: Props) => {
9 | const props = useElement(element)
10 | return (
11 |
23 | )
24 | }
25 |
26 | export default Ellipse
27 |
--------------------------------------------------------------------------------
/src/components/Draw/Elements/Image.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Image as KonvaImage } from 'react-konva'
3 | import useImage from 'use-image'
4 | import { useElement } from '../hooks/useElement'
5 | import { ImageElement } from '../../../types/draw'
6 |
7 | interface Props {
8 | element: ImageElement
9 | }
10 |
11 | const Image = ({ element }: Props) => {
12 | const [image] = useImage(element.src)
13 | const props = useElement(element)
14 |
15 | return (
16 |
24 | )
25 | }
26 |
27 | export default Image
28 |
--------------------------------------------------------------------------------
/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/Rect.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Rect as KonvaRect } from 'react-konva'
3 | import { useElement } from '../hooks/useElement'
4 | import { RectElement } from '../../../types/draw'
5 |
6 | interface Props {
7 | element: RectElement
8 | }
9 | const Rect = ({ element }: Props) => {
10 | const props = useElement(element)
11 |
12 | return (
13 |
25 | )
26 | }
27 |
28 | export default Rect
29 |
--------------------------------------------------------------------------------
/src/components/Draw/Header/Download.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from 'react'
2 | import Button from '@mui/material/Button'
3 | import { useStageContext } from '../contexts/StageProvider'
4 | import { useAppSelector } from '../../../hooks/useAppSelector'
5 | import {
6 | selectDefaultImages,
7 | selectSelectedElement,
8 | selectSelectedPage,
9 | } from '../../../reducers/draw'
10 | import { ToZipInput } from '../../../types'
11 | import { toZip } from '../../../utils/toZip'
12 | import { saveAs } from '../../../utils/saveAs'
13 | import { selectUrl } from '../../../reducers/app'
14 | import { extractHostname } from '../../../utils/url'
15 | import { shallowEqual } from 'react-redux'
16 |
17 | const Download = () => {
18 | const { getRef } = useStageContext()
19 | const images = useRef