├── .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 | drawing 4 | 5 | [![Chrome Web Store](https://img.shields.io/chrome-web-store/d/inmopeiepgfljkpkidclfgbgbmfcennb.svg?style=for-the-badge&label=Chrome%20users&ogo=google-chrome&logoColor=white)][chrome] 6 | [![Chrome Web Store](https://img.shields.io/chrome-web-store/v/inmopeiepgfljkpkidclfgbgbmfcennb.svg?style=for-the-badge&logo=google-chrome&logoColor=white)][chrome] 7 | [![Chrome Web Store](https://img.shields.io/chrome-web-store/stars/inmopeiepgfljkpkidclfgbgbmfcennb.svg?style=for-the-badge&logo=google-chrome&logoColor=white)][chrome] 8 | [![Website](https://img.shields.io/static/v1?label=website&message=responsiveviewer.org&style=for-the-badge&color=red)][website] 9 | 10 | Responsive Viewer - View multiple screen dimensions in one view | Product Hunt 11 | 12 | ![Screenshot](https://raw.githubusercontent.com/skmail/responsive-viewer/master/screenshot.png) 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 |
84 | 85 | 86 | 97 | 98 | 104 | {formUrl === url && } 105 | {formUrl !== url && } 106 | 107 | 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 | 50 | handleMenuItemClick(ScreenshotType.partial)}> 51 | Capture visible page 52 | 53 | handleMenuItemClick(ScreenshotType.full)}> 54 | Capture full page 55 | 56 | 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 | 11 | 21 | 31 | 41 | 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>(new Map()) 20 | const element = useAppSelector(selectSelectedElement) 21 | const pageName = useAppSelector(state => selectSelectedPage(state)?.name) 22 | const url = useAppSelector(selectUrl) 23 | 24 | const defaultImages = useAppSelector( 25 | state => selectDefaultImages(state), 26 | shallowEqual 27 | ) 28 | 29 | useEffect(() => { 30 | defaultImages.forEach(image => { 31 | if (!images.current.has(image.name)) { 32 | images.current.set(image.name, image.url) 33 | } 34 | }) 35 | }, [defaultImages]) 36 | 37 | const download = async () => { 38 | const files: ToZipInput = [] 39 | 40 | if (images.current.size === 1) { 41 | const [imageUrl] = Array.from(images.current.values()) 42 | 43 | const blob = await (await fetch(imageUrl)).blob() 44 | 45 | saveAs(blob, `${extractHostname(url)}-screenshot.png`) 46 | return 47 | } 48 | images.current.forEach((url, name) => { 49 | files.push({ 50 | filename: name, 51 | url: url, 52 | }) 53 | }) 54 | 55 | const zip = await toZip(files) 56 | 57 | saveAs(zip, `${extractHostname(url)}-screenshots.zip`) 58 | } 59 | 60 | useEffect(() => { 61 | const stage = getRef('stage') 62 | const transformer = getRef('transformer') 63 | if (!stage || !stage.width() || !stage.height()) { 64 | return 65 | } 66 | 67 | const timer = setTimeout(() => { 68 | transformer?.hide() 69 | images.current.set(pageName, stage.toDataURL()) 70 | transformer?.show() 71 | }, 50) 72 | 73 | return () => { 74 | clearTimeout(timer) 75 | } 76 | }, [pageName, element, getRef]) 77 | return ( 78 | 81 | ) 82 | } 83 | 84 | export default Download 85 | -------------------------------------------------------------------------------- /src/components/Draw/Header/Navigation.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import Button from '@mui/material/Button' 4 | import { useAppSelector } from '../../../hooks/useAppSelector' 5 | import { 6 | nextPage, 7 | previousPage, 8 | selectHasNextPage, 9 | selectHasPreviousPage, 10 | } from '../../../reducers/draw' 11 | import { useAppDispatch } from '../../../hooks/useAppDispatch' 12 | const Navigation = () => { 13 | const hasNextPage = useAppSelector(selectHasNextPage) 14 | const hasPreviousPage = useAppSelector(selectHasPreviousPage) 15 | const dispatch = useAppDispatch() 16 | 17 | return ( 18 | <> 19 | 27 | 35 | 36 | ) 37 | } 38 | 39 | export default Navigation 40 | -------------------------------------------------------------------------------- /src/components/Draw/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import DialogToolbar from '@mui/material/Toolbar' 2 | import AppBar from '@mui/material/AppBar' 3 | 4 | import Stack from '@mui/material/Stack' 5 | import IconButton from '@mui/material/IconButton' 6 | import Typography from '@mui/material/Typography' 7 | import CloseIcon from '@mui/icons-material/Close' 8 | import { styled } from '@mui/material/styles' 9 | import Navigation from './Navigation' 10 | import { useAppSelector } from '../../../hooks/useAppSelector' 11 | import { selectSelectedPage } from '../../../reducers/draw' 12 | import Download from './Download' 13 | 14 | const Root = styled(AppBar)(({ theme }) => ({ 15 | position: 'relative', 16 | })) 17 | 18 | interface Props { 19 | onClose: () => void 20 | } 21 | 22 | const Header = ({ onClose }: Props) => { 23 | const pageName = useAppSelector(state => selectSelectedPage(state)?.name) 24 | return ( 25 | 26 | 27 | 33 | 34 | 35 | 36 | Edit screenshots ({pageName}) 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | ) 45 | } 46 | 47 | export default Header 48 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/components/Draw/Toolbar/settings/ColorPopover.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useState, MouseEvent } from 'react' 2 | import MuiPopover from '@mui/material/Popover' 3 | import Button from '@mui/material/Button' 4 | import Color from './Color' 5 | import { styled } from '@mui/material/styles' 6 | import { ColorChangeHandler, Color as ColorType } from 'react-color' 7 | const width = 170 8 | const Popover = styled(MuiPopover)(({ theme }) => ({ 9 | '& .MuiPaper-root': { 10 | width, 11 | padding: theme.spacing(1), 12 | boxShadow: `0 0 0 2px ${theme.palette.primary.main}`, 13 | }, 14 | })) 15 | interface Props { 16 | color?: ColorType 17 | onChange: ColorChangeHandler 18 | children: ReactNode 19 | } 20 | const ColorPopover = ({ color, onChange, children }: Props) => { 21 | const [anchorEl, setAnchorEl] = useState(null) 22 | 23 | const handleClick = (event: MouseEvent) => { 24 | setAnchorEl(event.currentTarget as HTMLButtonElement) 25 | } 26 | 27 | const handleClose = () => { 28 | setAnchorEl(null) 29 | } 30 | 31 | const open = Boolean(anchorEl) 32 | 33 | const id = open ? 'color-popover' : undefined 34 | 35 | return ( 36 | <> 37 | 40 | 50 | 51 | 52 | 53 | ) 54 | } 55 | 56 | export default ColorPopover 57 | -------------------------------------------------------------------------------- /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/Draw/Transformer.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { Transformer as KonvaTransformer } from 'react-konva' 3 | import { useAppSelector } from '../../hooks/useAppSelector' 4 | import { selectSelectedElementId } from '../../reducers/draw' 5 | import { useStageContext } from './contexts/StageProvider' 6 | 7 | const Transformer = () => { 8 | const { getRef, setRef } = useStageContext() 9 | 10 | const selected = useAppSelector(selectSelectedElementId) 11 | 12 | useEffect(() => { 13 | const transformer = getRef('transformer') 14 | if (!selected || !transformer) { 15 | return 16 | } 17 | 18 | transformer.nodes([getRef(selected).current]) 19 | transformer.getLayer()?.batchDraw() 20 | }, [selected, getRef]) 21 | 22 | if (!selected) { 23 | return null 24 | } 25 | return ( 26 | { 28 | if (ref) { 29 | setRef('transformer', ref) 30 | } 31 | }} 32 | boundBoxFunc={(oldBox, newBox) => { 33 | // limit resize 34 | if (newBox.width < 5 || newBox.height < 5) { 35 | return oldBox 36 | } 37 | return newBox 38 | }} 39 | /> 40 | ) 41 | } 42 | 43 | export default Transformer 44 | -------------------------------------------------------------------------------- /src/components/Draw/contexts/StageProvider.tsx: -------------------------------------------------------------------------------- 1 | import Konva from 'konva' 2 | import { 3 | createContext, 4 | useContext, 5 | useCallback, 6 | ReactNode, 7 | RefObject, 8 | useEffect, 9 | } from 'react' 10 | 11 | interface StageContextInterface { 12 | getRef: GetRef 13 | setRef: SetRef 14 | } 15 | type GetRef = (id: string) => T 16 | type SetRef = (id: string, ref: T) => T 17 | 18 | const StageContext = createContext({ 19 | getRef: () => null, 20 | setRef: (id, ref) => ref, 21 | }) 22 | 23 | export const useStageContext = () => useContext(StageContext) 24 | 25 | interface Props { 26 | children: ReactNode 27 | stageRef?: RefObject 28 | } 29 | 30 | const store = new Map() 31 | 32 | export const StageProvider = ({ children, stageRef }: Props) => { 33 | const getRef: GetRef = useCallback(id => store.get(id), []) 34 | const setRef: SetRef = useCallback((id, ref) => { 35 | store.set(id, ref) 36 | return ref 37 | }, []) 38 | 39 | useEffect(() => { 40 | if (stageRef && stageRef.current) { 41 | setRef('stage', stageRef.current) 42 | } 43 | }, [setRef, stageRef]) 44 | 45 | return ( 46 | 52 | {children} 53 | 54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /src/components/Draw/hooks/useDrawingTool.ts: -------------------------------------------------------------------------------- 1 | import Konva from 'konva' 2 | import { RefObject, useEffect, useRef } from 'react' 3 | import { useAppDispatch } from '../../../hooks/useAppDispatch' 4 | import { useAppSelector } from '../../../hooks/useAppSelector' 5 | import { 6 | addElement, 7 | selectDrawingTool, 8 | selectLatestStyles, 9 | } from '../../../reducers/draw' 10 | import { Element } from '../../../types/draw' 11 | import { tools } from '../tools' 12 | export function useDrawingTool(stageRef: RefObject, zoom = 1) { 13 | const drawingTool = useAppSelector(selectDrawingTool) 14 | const dispatch = useAppDispatch() 15 | const latestStyles = useRef>({}) 16 | 17 | useAppSelector(state => { 18 | latestStyles.current = selectLatestStyles(state) 19 | }) 20 | 21 | useEffect(() => { 22 | if (!drawingTool || !stageRef.current) { 23 | return 24 | } 25 | 26 | const stage = stageRef.current 27 | 28 | const onDown = (event: Konva.KonvaEventObject) => { 29 | event.evt.stopPropagation() 30 | const stageBox = stage.content.getBoundingClientRect() 31 | 32 | const tool = new tools[drawingTool]( 33 | { 34 | tool: drawingTool, 35 | x: (event.evt.pageX - stageBox.x) / zoom, 36 | y: (event.evt.pageY - stageBox.y) / zoom, 37 | latestStyles: latestStyles.current, 38 | }, 39 | 40 | stage 41 | ) 42 | 43 | const onMove = (event: MouseEvent) => { 44 | tool.move({ 45 | x: (event.pageX - stageBox.x) / zoom, 46 | y: (event.pageY - stageBox.y) / zoom, 47 | }) 48 | } 49 | const onUp = () => { 50 | document.removeEventListener('mousemove', onMove) 51 | document.removeEventListener('mouseup', onUp) 52 | const result = tool.finished() 53 | if (result) { 54 | dispatch(addElement(result as Element)) 55 | } 56 | } 57 | 58 | document.addEventListener('mousemove', onMove) 59 | document.addEventListener('mouseup', onUp) 60 | } 61 | 62 | stage.on('mousedown', onDown) 63 | 64 | return () => { 65 | stage.off('mousedown') 66 | } 67 | }, [drawingTool, dispatch, stageRef, zoom]) 68 | } 69 | -------------------------------------------------------------------------------- /src/components/Draw/hooks/useElement.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useMemo, useRef } from 'react' 2 | import { useAppDispatch } from '../../../hooks/useAppDispatch' 3 | import { useAppSelector } from '../../../hooks/useAppSelector' 4 | import { 5 | selectDrawingTool, 6 | setSelectedElement, 7 | updateElement, 8 | } from '../../../reducers/draw' 9 | import { Element } from '../../../types/draw' 10 | import { useStageContext } from '../contexts/StageProvider' 11 | import { applyStrokeDashArray } from '../utils/stroke' 12 | 13 | export function useElement(element: Element) { 14 | const { setRef } = useStageContext() 15 | 16 | // TODO use correct Typing here 17 | const ref = useRef() 18 | const dispatch = useAppDispatch() 19 | 20 | useEffect(() => { 21 | setRef(element.id, ref) 22 | }, [element.id, setRef]) 23 | 24 | const select = useCallback(() => { 25 | dispatch(setSelectedElement(element.id)) 26 | }, [element.id, dispatch]) 27 | 28 | const drawingTool = useAppSelector(selectDrawingTool) 29 | 30 | const dash = useMemo( 31 | () => applyStrokeDashArray(element.dash, element.strokeWidth), 32 | [element.dash, element.strokeWidth] 33 | ) 34 | 35 | const draggable = element.draggable !== false && !drawingTool 36 | 37 | return { 38 | id: element.id, 39 | ref, 40 | onClick: select, 41 | onTap: select, 42 | draggable, 43 | dash, 44 | onDragEnd: () => { 45 | dispatch( 46 | updateElement({ 47 | id: element.id, 48 | props: { 49 | x: ref.current.x(), 50 | y: ref.current.y(), 51 | }, 52 | }) 53 | ) 54 | }, 55 | onTransformEnd: () => { 56 | dispatch( 57 | updateElement({ 58 | id: element.id, 59 | props: { 60 | x: ref.current.x(), 61 | y: ref.current.y(), 62 | width: ref.current.width(), 63 | height: ref.current.height(), 64 | }, 65 | }) 66 | ) 67 | }, 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/components/Draw/hooks/useKeyboardShortcuts.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { useAppDispatch } from '../../../hooks/useAppDispatch' 3 | import { useAppSelector } from '../../../hooks/useAppSelector' 4 | import { removeElement, selectSelectedElementId } from '../../../reducers/draw' 5 | 6 | export const useKeyboardShortcuts = () => { 7 | const selected = useAppSelector(selectSelectedElementId) 8 | const dispatch = useAppDispatch() 9 | 10 | useEffect(() => { 11 | const onKeyDown = (e: KeyboardEvent) => { 12 | const target = (e.target as HTMLElement).tagName.toLowerCase() 13 | 14 | if (['textarea', 'input'].includes(target)) { 15 | return 16 | } 17 | 18 | if (e.code === 'Backspace' || e.code === 'Delete') { 19 | if (selected) { 20 | dispatch(removeElement(selected)) 21 | } 22 | } 23 | } 24 | 25 | window.addEventListener('keydown', onKeyDown) 26 | 27 | return () => { 28 | window.removeEventListener('keydown', onKeyDown) 29 | } 30 | }, [selected, dispatch]) 31 | } 32 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/components/Draw/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Dialog from '@mui/material/Dialog' 3 | import DialogContent from '@mui/material/DialogContent' 4 | import Toolbar from './Toolbar' 5 | import { styled } from '@mui/material/styles' 6 | import Header from './Header' 7 | import DialogWrapper from './DialogWrapper' 8 | import { useAppDispatch } from '../../hooks/useAppDispatch' 9 | import { useAppSelector } from '../../hooks/useAppSelector' 10 | import { closeDraw, selectIsDrawOpen } from '../../reducers/draw' 11 | import { StageProvider } from './contexts/StageProvider' 12 | const Content = styled(DialogContent)(({ theme }) => ({ 13 | padding: theme.spacing(2), 14 | height: 100, 15 | overflow: 'hidden', 16 | })) 17 | 18 | const DrawDialog = () => { 19 | const dispatch = useAppDispatch() 20 | const open = useAppSelector(selectIsDrawOpen) 21 | const handleClose = () => { 22 | dispatch(closeDraw()) 23 | } 24 | const id = 'draw-app' 25 | 26 | return ( 27 | 28 | 29 |
30 | 31 | 32 | 33 | 34 | 35 |
36 | ) 37 | } 38 | 39 | export default DrawDialog 40 | -------------------------------------------------------------------------------- /src/components/Draw/tools/arrow.ts: -------------------------------------------------------------------------------- 1 | import Tool from './tool' 2 | import Konva from 'konva' 3 | import { applyStrokeDashArray } from '../utils/stroke' 4 | import { Element } from '../../../types/draw' 5 | 6 | export default class Circle extends Tool { 7 | startX = 0 8 | startY = 0 9 | tool: string 10 | instance: Konva.Arrow 11 | 12 | constructor( 13 | { 14 | x, 15 | y, 16 | tool, 17 | latestStyles, 18 | }: { x: number; y: number; tool: string; latestStyles: Partial }, 19 | stage: Konva.Stage 20 | ) { 21 | super(stage) 22 | this.startX = x 23 | this.startY = y 24 | 25 | this.tool = tool 26 | 27 | const strokeWidth = latestStyles.strokeWidth || 2 28 | 29 | this.instance = new Konva.Arrow({ 30 | stroke: latestStyles.stroke || 'red', 31 | fill: latestStyles.stroke || 'red', 32 | strokeWidth, 33 | dash: applyStrokeDashArray(latestStyles.dash, strokeWidth), 34 | lineCap: 'round', 35 | lineJoin: 'round', 36 | points: [this.startX, this.startY], 37 | }) 38 | 39 | this.layer.add(this.instance) 40 | } 41 | 42 | move({ x, y }: { x: number; y: number }) { 43 | x = x - 1 44 | y = y - 1 45 | if (this.tool === 'arrow-pen') { 46 | const points = this.instance.points() 47 | const lastX = points[points.length - 2] 48 | const lastY = points[points.length - 1] 49 | if (lastX !== x && lastY !== y) { 50 | this.instance.points(points.concat([x, y])) 51 | } 52 | } else { 53 | this.instance.points([this.startX, this.startY, x, y]) 54 | } 55 | } 56 | 57 | finished() { 58 | this.instance.destroy() 59 | return this.createDataElement({ 60 | type: 'arrow', 61 | points: this.instance.points(), 62 | fill: this.instance.stroke(), 63 | stroke: this.instance.stroke(), 64 | strokeWidth: this.instance.strokeWidth(), 65 | dash: this.instance.dash(), 66 | }) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/components/Draw/tools/circle.ts: -------------------------------------------------------------------------------- 1 | import Tool from './tool' 2 | import Konva from 'konva' 3 | import { applyStrokeDashArray } from '../utils/stroke' 4 | import { Element } from '../../../types/draw' 5 | 6 | export default class Circle extends Tool { 7 | startX = 0 8 | startY = 0 9 | instance: Konva.Circle 10 | constructor( 11 | { 12 | x, 13 | y, 14 | latestStyles, 15 | }: { x: number; y: number; latestStyles: Partial }, 16 | stage: Konva.Stage 17 | ) { 18 | super(stage) 19 | this.startX = x 20 | this.startY = y 21 | const strokeWidth = latestStyles.strokeWidth || 2 22 | 23 | this.instance = new Konva.Circle({ 24 | stroke: latestStyles.stroke || 'red', 25 | fill: latestStyles.fill, 26 | strokeWidth, 27 | dash: applyStrokeDashArray(latestStyles.dash, strokeWidth), 28 | lineCap: 'round', 29 | lineJoin: 'round', 30 | }) 31 | this.layer.add(this.instance) 32 | } 33 | 34 | move({ x, y }: { x: number; y: number }) { 35 | const radius = Math.sqrt( 36 | Math.pow(x - this.startX, 2) + Math.pow(y - this.startY, 2) 37 | ) 38 | 39 | this.instance.radius(radius) 40 | this.instance.x(this.startX) 41 | this.instance.y(this.startY) 42 | } 43 | 44 | finished() { 45 | this.instance.destroy() 46 | return this.createDataElement({ 47 | type: 'circle', 48 | x: this.instance.x(), 49 | y: this.instance.y(), 50 | radius: this.instance.radius(), 51 | fill: this.instance.fill(), 52 | stroke: this.instance.stroke(), 53 | strokeWidth: this.instance.strokeWidth(), 54 | dash: this.instance.dash(), 55 | }) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/components/Draw/tools/ellipse.ts: -------------------------------------------------------------------------------- 1 | import Tool from './tool' 2 | import Konva from 'konva' 3 | import { applyStrokeDashArray } from '../utils/stroke' 4 | import { Element } from '../../../types/draw' 5 | 6 | export default class Ellipse extends Tool { 7 | startX = 0 8 | startY = 0 9 | instance: Konva.Ellipse 10 | constructor( 11 | { 12 | x, 13 | y, 14 | latestStyles, 15 | }: { x: number; y: number; latestStyles: Partial }, 16 | stage: Konva.Stage 17 | ) { 18 | super(stage) 19 | this.startX = x 20 | this.startY = y 21 | const strokeWidth = latestStyles.strokeWidth || 2 22 | 23 | this.instance = new Konva.Ellipse({ 24 | stroke: latestStyles.stroke || 'red', 25 | fill: latestStyles.fill, 26 | strokeWidth, 27 | dash: applyStrokeDashArray(latestStyles.dash, strokeWidth), 28 | lineCap: 'round', 29 | lineJoin: 'round', 30 | radiusX: this.startX, 31 | radiusY: this.startY, 32 | }) 33 | this.layer.add(this.instance) 34 | } 35 | 36 | move({ x, y }: { x: number; y: number }) { 37 | const [radiusX, drawX] = this.drawPoint(x, this.startX) 38 | const [radiusY, drawY] = this.drawPoint(y, this.startY) 39 | 40 | this.instance.x(drawX + radiusX) 41 | this.instance.y(drawY + radiusY) 42 | this.instance.radiusX(radiusX) 43 | this.instance.radiusY(radiusY) 44 | } 45 | 46 | drawPoint(point1: number, point2: number) { 47 | if (point1 < point2) { 48 | return [(point2 - point1) * 0.5, point1] 49 | } else { 50 | return [(point1 - point2) * 0.5, point2] 51 | } 52 | } 53 | 54 | finished() { 55 | this.instance.destroy() 56 | return this.createDataElement({ 57 | type: 'ellipse', 58 | x: this.instance.x(), 59 | y: this.instance.y(), 60 | radiusX: this.instance.radiusX(), 61 | radiusY: this.instance.radiusY(), 62 | 63 | fill: this.instance.fill(), 64 | stroke: this.instance.stroke(), 65 | strokeWidth: this.instance.strokeWidth(), 66 | dash: this.instance.dash(), 67 | }) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /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/Draw/tools/pen.ts: -------------------------------------------------------------------------------- 1 | import Tool from './tool' 2 | import Konva from 'konva' 3 | import { applyStrokeDashArray } from '../utils/stroke' 4 | import { Element } from '../../../types/draw' 5 | 6 | export default class Pen extends Tool { 7 | startX = 0 8 | startY = 0 9 | width = 0 10 | height = 0 11 | instance: Konva.Line 12 | constructor( 13 | { 14 | x, 15 | y, 16 | latestStyles, 17 | }: { x: number; y: number; latestStyles: Partial }, 18 | stage: Konva.Stage 19 | ) { 20 | super(stage) 21 | this.startX = x 22 | this.startY = y 23 | const strokeWidth = latestStyles.strokeWidth || 2 24 | this.instance = new Konva.Line({ 25 | stroke: latestStyles.stroke || 'red', 26 | fill: latestStyles.stroke || 'red', 27 | strokeWidth, 28 | dash: applyStrokeDashArray(latestStyles.dash, strokeWidth), 29 | lineCap: 'round', 30 | lineJoin: 'round', 31 | points: [this.startX, this.startY], 32 | }) 33 | this.layer.add(this.instance) 34 | } 35 | 36 | move({ x, y }: { x: number; y: number }) { 37 | this.instance.points(this.instance.points().concat([x, y])) 38 | } 39 | 40 | finished() { 41 | this.instance.destroy() 42 | return this.createDataElement({ 43 | type: 'pen', 44 | points: this.instance.points(), 45 | fill: this.instance.stroke(), 46 | stroke: this.instance.stroke(), 47 | strokeWidth: this.instance.strokeWidth(), 48 | dash: this.instance.dash(), 49 | }) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/components/Draw/tools/rect.ts: -------------------------------------------------------------------------------- 1 | import Tool from './tool' 2 | import Konva from 'konva' 3 | import { applyStrokeDashArray } from '../utils/stroke' 4 | import { Element } from '../../../types/draw' 5 | 6 | export default class Rect extends Tool { 7 | startX = 0 8 | startY = 0 9 | width = 0 10 | height = 0 11 | instance: Konva.Rect 12 | 13 | constructor( 14 | { 15 | x, 16 | y, 17 | latestStyles, 18 | }: { x: number; y: number; latestStyles: Partial }, 19 | stage: Konva.Stage 20 | ) { 21 | super(stage) 22 | this.startX = x 23 | this.startY = y 24 | const strokeWidth = latestStyles.strokeWidth || 2 25 | 26 | this.instance = new Konva.Rect({ 27 | stroke: latestStyles.stroke || 'red', 28 | fill: latestStyles.fill, 29 | strokeWidth, 30 | dash: applyStrokeDashArray(latestStyles.dash, strokeWidth), 31 | lineCap: 'round', 32 | lineJoin: 'round', 33 | }) 34 | this.layer.add(this.instance) 35 | } 36 | 37 | move({ x, y }: { x: number; y: number }) { 38 | this.instance.x(x < this.startX ? x : this.startX) 39 | this.instance.y(y < this.startY ? y : this.startY) 40 | this.instance.width(x < this.startX ? this.startX - x : x - this.startX) 41 | this.instance.height(y < this.startY ? this.startY - y : y - this.startY) 42 | } 43 | 44 | finished() { 45 | this.instance.destroy() 46 | 47 | return this.createDataElement({ 48 | type: 'rect', 49 | x: this.instance.x(), 50 | y: this.instance.y(), 51 | width: this.instance.width(), 52 | height: this.instance.height(), 53 | 54 | fill: this.instance.fill(), 55 | stroke: this.instance.stroke(), 56 | strokeWidth: this.instance.strokeWidth(), 57 | dash: this.instance.dash(), 58 | }) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/components/Draw/tools/text.ts: -------------------------------------------------------------------------------- 1 | import Tool from './tool' 2 | import { Element } from '../../../types/draw' 3 | import Konva from 'konva' 4 | 5 | export default class Text extends Tool { 6 | startX = 0 7 | startY = 0 8 | width = 0 9 | height = 0 10 | fill = 'red' 11 | constructor( 12 | { 13 | x, 14 | y, 15 | latestStyles, 16 | }: { x: number; y: number; latestStyles: Partial }, 17 | stage: Konva.Stage 18 | ) { 19 | super(stage) 20 | this.startX = x 21 | this.startY = y 22 | this.fill = latestStyles.fill || 'red' 23 | } 24 | 25 | finished() { 26 | return this.createDataElement({ 27 | type: 'text', 28 | x: this.startX, 29 | y: this.startY, 30 | width: 200, 31 | fill: this.fill, 32 | text: 'Place text here', 33 | }) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /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/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/EmptyState.tsx: -------------------------------------------------------------------------------- 1 | import React, { ElementType } from 'react' 2 | import Box from '@mui/material/Box' 3 | import { styled } from '@mui/material/styles' 4 | 5 | const Root = styled('div')(({ theme }) => ({ 6 | minHeight: `100%`, 7 | display: 'flex', 8 | alignItems: 'center', 9 | justifyContent: 'center', 10 | flexDirection: 'column', 11 | fontSize: 20, 12 | color: theme.palette.text.secondary, 13 | })) 14 | 15 | interface Props { 16 | icon: ElementType 17 | message: string 18 | } 19 | const EmptyState = ({ icon, message }: Props) => { 20 | return ( 21 | 22 | 23 | {message} 24 | 25 | ) 26 | } 27 | 28 | export default EmptyState 29 | -------------------------------------------------------------------------------- /src/components/HelpDialog.tsx: -------------------------------------------------------------------------------- 1 | import React, { 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 Chip from '@mui/material/Chip' 9 | import { useAppSelector } from '../hooks/useAppSelector' 10 | import { selectHelpDialog, toggleHelpDialog } from '../reducers/layout' 11 | import { useAppDispatch } from '../hooks/useAppDispatch' 12 | import { appReset } from '../reducers/app' 13 | 14 | const HelpDialog = () => { 15 | const [isAppResetOpened, setIsAppResetOpened] = useState(false) 16 | 17 | const helpDialog = useAppSelector(selectHelpDialog) 18 | const dispatch = useAppDispatch() 19 | 20 | const id = helpDialog.open ? 'help-dialog' : undefined 21 | 22 | const onClose = () => { 23 | dispatch(toggleHelpDialog()) 24 | } 25 | const onAppReset = () => { 26 | dispatch(appReset()) 27 | setIsAppResetOpened(false) 28 | onClose() 29 | } 30 | 31 | return ( 32 |
33 | 34 | Help! 35 | 36 |
37 | 38 | Edit Screen by on the 39 | screen name from sidebar. 40 | 41 | 42 | 43 | Hold while scrolling to 44 | disable iframe scroll. 45 | 46 | 47 | 48 | 51 | 52 | Confirm App Reset? 53 | 54 | 55 | All screens & settings will be reset to initial state, you 56 | cannot undo this action. 57 | 58 | 59 | 60 | 67 | 70 | 71 | 72 | 75 | 76 |
77 |
78 |
79 |
80 | ) 81 | } 82 | 83 | export default HelpDialog 84 | -------------------------------------------------------------------------------- /src/components/LoadingScreen.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { styled } from '@mui/material/styles' 3 | import AppLogo from './AppLogo' 4 | import Typography from '@mui/material/Typography' 5 | import { keyframes } from '@mui/system' 6 | 7 | const fadeIn = keyframes` 8 | 0% { opacity: 0; transform: translateX(100px); } 9 | 100% { opacity: 1; transform: translateX(0); } 10 | ` 11 | 12 | const Root = styled('div')(({ theme }) => ({ 13 | padding: theme.spacing(4, 8), 14 | display: 'flex', 15 | justifyContent: 'center', 16 | flexDirection: 'column', 17 | height: '100vh', 18 | overflow: 'hidden', 19 | })) 20 | const Logo = styled(AppLogo)(({ theme }) => ({ 21 | width: 200, 22 | 23 | opacity: 0, 24 | animationName: fadeIn, 25 | animationDuration: '3s', 26 | animationDelay: '0.2s', 27 | animationFillMode: 'forwards', 28 | marginBottom: theme.spacing(2), 29 | [theme.breakpoints.down('lg')]: { 30 | width: 150, 31 | }, 32 | 33 | [theme.breakpoints.down('sm')]: { 34 | width: 100, 35 | }, 36 | })) 37 | const Title = styled(Typography)(({ theme }) => ({ 38 | height: 100, 39 | opacity: 0, 40 | animationName: fadeIn, 41 | animationDuration: '3s', 42 | animationDelay: '0.4s', 43 | animationFillMode: 'forwards', 44 | [theme.breakpoints.down('lg')]: { 45 | fontSize: 70, 46 | }, 47 | [theme.breakpoints.down('md')]: { 48 | fontSize: 50, 49 | }, 50 | [theme.breakpoints.down('sm')]: { 51 | fontSize: 30, 52 | }, 53 | })) 54 | const LoadingScreen = () => { 55 | return ( 56 | 57 | 58 | 59 | Responsive Viewer 60 | 61 | ) 62 | } 63 | 64 | export default LoadingScreen 65 | -------------------------------------------------------------------------------- /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/components/Notifications/index.tsx: -------------------------------------------------------------------------------- 1 | import Button from '@mui/material/Button' 2 | import Snackbar from '@mui/material/Snackbar' 3 | import MuiAlert from '@mui/material/Alert' 4 | import { styled } from '@mui/material/styles' 5 | import Stack from '@mui/material/Stack' 6 | import Box from '@mui/material/Box' 7 | import CircularProgress from '@mui/material/CircularProgress' 8 | import { useAppSelector } from '../../hooks/useAppSelector' 9 | import { useAppDispatch } from '../../hooks/useAppDispatch' 10 | import { 11 | removeNotification, 12 | selectNotifications, 13 | } from '../../reducers/notifications' 14 | import { useMemo } from 'react' 15 | 16 | const Alert = styled(MuiAlert)(({ theme }) => ({ 17 | alignItems: 'center', 18 | '& > .MuiAlert-message': { 19 | display: 'flex', 20 | alignItems: 'center', 21 | padding: 0, 22 | }, 23 | '& > .MuiAlert-action': { 24 | padding: 0, 25 | }, 26 | })) 27 | 28 | export default function Notifications() { 29 | const notification = useAppSelector(selectNotifications) 30 | const open = Boolean(notification) 31 | const dispatch = useAppDispatch() 32 | 33 | const handleClose = useMemo(() => { 34 | if (!notification || notification.cancellable === false) { 35 | return 36 | } 37 | 38 | return () => { 39 | dispatch(removeNotification()) 40 | } 41 | }, [notification, dispatch]) 42 | 43 | return ( 44 | 51 | : undefined 54 | } 55 | variant="filled" 56 | severity={notification?.type} 57 | onClose={handleClose} 58 | > 59 | {open && ( 60 | 66 | {notification.message} 67 | 68 | {!!notification.actions && 69 | notification.actions.map(action => ( 70 | 77 | ))} 78 | 79 | 80 | )} 81 | 82 | 83 | ) 84 | } 85 | -------------------------------------------------------------------------------- /src/components/Screen/Actions/Screenshot.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, MouseEvent } from 'react' 2 | import CameraIcon from '@mui/icons-material/CameraAlt' 3 | import IconButton from '@mui/material/IconButton' 4 | 5 | import MenuItem from '@mui/material/MenuItem' 6 | import Menu from '@mui/material/Menu' 7 | import { ScreenshotType } from '../../../types' 8 | import { useAppDispatch } from '../../../hooks/useAppDispatch' 9 | import { screenshot } from '../../../reducers/screenshots' 10 | 11 | interface Props { 12 | id: string 13 | } 14 | 15 | const Screenshot = ({ id }: Props) => { 16 | const dispatch = useAppDispatch() 17 | 18 | const [anchorEl, setAnchorEl] = useState(null) 19 | 20 | const onClick = (event: MouseEvent) => { 21 | setAnchorEl(event.currentTarget as HTMLButtonElement) 22 | } 23 | 24 | const handleMenuItemClick = (type: ScreenshotType) => { 25 | dispatch( 26 | screenshot({ 27 | screens: [id], 28 | type, 29 | }) 30 | ) 31 | setAnchorEl(null) 32 | } 33 | 34 | const handleClose = () => { 35 | setAnchorEl(null) 36 | } 37 | 38 | return ( 39 | 40 | 47 | 48 | 49 | 50 | 63 | handleMenuItemClick(ScreenshotType.partial)}> 64 | Capture visible page 65 | 66 | handleMenuItemClick(ScreenshotType.full)}> 67 | Capture entire page 68 | 69 | 70 | 71 | ) 72 | } 73 | 74 | export default Screenshot 75 | -------------------------------------------------------------------------------- /src/components/Screen/Actions/Settings.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import SettingsIcon from '@mui/icons-material/Settings' 3 | import IconButton from '@mui/material/IconButton' 4 | import { useAppDispatch } from '../../../hooks/useAppDispatch' 5 | import { toggleScreenDialog } from '../../../reducers/layout' 6 | import { Device } from '../../../types' 7 | 8 | interface Props { 9 | screen: Device 10 | } 11 | const Settings = ({ screen }: Props) => { 12 | const dispatch = useAppDispatch() 13 | 14 | const onClick = () => { 15 | dispatch(toggleScreenDialog(screen)) 16 | } 17 | 18 | return ( 19 | 26 | 27 | 28 | ) 29 | } 30 | 31 | export default Settings 32 | -------------------------------------------------------------------------------- /src/components/Screen/Dimensions.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { useAppSelector } from '../../hooks/useAppSelector' 3 | import { 4 | saveScreen, 5 | selectScreenById, 6 | selectScreenDirection, 7 | } from '../../reducers/app' 8 | import { Box, darken, styled } from '@mui/material' 9 | import { useAppDispatch } from '../../hooks/useAppDispatch' 10 | 11 | const ScreenSize = styled('span')(({ theme }) => ({ 12 | fontSize: 12, 13 | color: darken(theme.palette.text.secondary, 0.3), 14 | display: 'flex', 15 | alignItems: 'center', 16 | })) 17 | 18 | const SizeInput = styled('input')(({ theme }) => ({ 19 | padding: 0, 20 | fontSize: 12, 21 | background: 'transparent', 22 | border: 'none', 23 | color: darken(theme.palette.text.secondary, 0.3), 24 | width: '100%', 25 | resize: 'none', 26 | outline: 'none', 27 | position: 'absolute', 28 | })) 29 | 30 | const InputRoot = styled('label')(({ theme }) => ({ 31 | display: 'inline-grid', 32 | position: 'relative', 33 | '&:after': { 34 | content: 'attr(data-value) ', 35 | visibility: 'hidden', 36 | whiteSpace: 'pre-wrap', 37 | lineHeight: 1.1, 38 | }, 39 | '&:focus-within': { 40 | outline: `2px solid ${theme.palette.primary.main}`, 41 | outlineOffset: 2, 42 | }, 43 | })) 44 | const Dimension = ({ 45 | value, 46 | onChange, 47 | }: { 48 | value: number 49 | onChange: (value: number) => void 50 | }) => { 51 | const [isEditing, setIsEditing] = useState(false) 52 | 53 | return ( 54 | <> 55 | {isEditing ? ( 56 | 57 | { 61 | let value = parseInt(e.target.value) 62 | if (isNaN(value)) { 63 | return 64 | } 65 | 66 | onChange(value) 67 | }} 68 | onBlur={() => setIsEditing(false)} 69 | /> 70 | 71 | ) : ( 72 | setIsEditing(true)}>{value} 73 | )} 74 | 75 | ) 76 | } 77 | export function Dimensions({ id }: { id: string }) { 78 | const screen = useAppSelector(state => selectScreenById(state, id)) 79 | 80 | const screenDirection = useAppSelector(selectScreenDirection) 81 | const dispatch = useAppDispatch() 82 | 83 | return ( 84 | 85 | { 88 | dispatch( 89 | saveScreen({ 90 | ...screen, 91 | [screenDirection !== 'landscape' ? 'width' : 'height']: value, 92 | }) 93 | ) 94 | }} 95 | /> 96 | x 97 | { 100 | dispatch( 101 | saveScreen({ 102 | ...screen, 103 | [screenDirection !== 'landscape' ? 'height' : 'width']: value, 104 | }) 105 | ) 106 | }} 107 | /> 108 | 109 | ) 110 | } 111 | -------------------------------------------------------------------------------- /src/components/Screen/Header.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Box from '@mui/material/Box' 3 | import Screenshot from './Actions/Screenshot' 4 | import Settings from './Actions/Settings' 5 | import { useAppSelector } from '../../hooks/useAppSelector' 6 | import { selectScreenById } from '../../reducers/app' 7 | import { styled } from '@mui/material/styles' 8 | import Stack from '@mui/material/Stack' 9 | import { Dimensions } from './Dimensions' 10 | 11 | const ScreenName = styled('span')(({ theme }) => ({ 12 | fontSize: 12, 13 | fontWeight: 'bold', 14 | color: theme.palette.text.secondary, 15 | })) 16 | 17 | interface Props { 18 | id: string 19 | } 20 | 21 | const Header = ({ id }: Props) => { 22 | const screen = useAppSelector(state => selectScreenById(state, id)) 23 | 24 | return ( 25 | 26 | 27 | {screen.name} 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | ) 37 | } 38 | 39 | export default Header 40 | -------------------------------------------------------------------------------- /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/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 |