diff --git a/mx-interpreter/index.ts b/mx-interpreter/index.ts new file mode 100644 index 00000000..571f5781 --- /dev/null +++ b/mx-interpreter/index.ts @@ -0,0 +1,8 @@ +import Interpreter from './interpret'; + +export default Interpreter; +export { default as Preprocessor } from './preprocessor'; +export type { + WorkflowFile, WhereWhatPair, Where, What, +} from './types/workflow'; +export { unaryOperators, naryOperators, meta as metaOperators } from './types/logic'; \ No newline at end of file diff --git a/mx-interpreter/utils/concurrency.ts b/mx-interpreter/utils/concurrency.ts new file mode 100644 index 00000000..eec7eb33 --- /dev/null +++ b/mx-interpreter/utils/concurrency.ts @@ -0,0 +1,85 @@ +/** + * Concurrency class for running concurrent tasks while managing a limited amount of resources. + */ +export default class Concurrency { + /** + * Maximum number of workers running in parallel. If set to `null`, there is no limit. + */ + maxConcurrency: number = 1; + + /** + * Number of currently active workers. + */ + activeWorkers: number = 0; + + /** + * Queue of jobs waiting to be completed. + */ + private jobQueue: Function[] = []; + + /** + * "Resolve" callbacks of the waitForCompletion() promises. + */ + private waiting: Function[] = []; + + /** + * Constructs a new instance of concurrency manager. + * @param {number} maxConcurrency Maximum number of workers running in parallel. + */ + constructor(maxConcurrency: number) { + this.maxConcurrency = maxConcurrency; + } + + /** + * Takes a waiting job out of the queue and runs it. + */ + private runNextJob(): void { + const job = this.jobQueue.pop(); + + if (job) { + // console.debug("Running a job..."); + job().then(() => { + // console.debug("Job finished, running the next waiting job..."); + this.runNextJob(); + }); + } else { + // console.debug("No waiting job found!"); + this.activeWorkers -= 1; + if (this.activeWorkers === 0) { + // console.debug("This concurrency manager is idle!"); + this.waiting.forEach((x) => x()); + } + } + } + + /** + * Pass a job (a time-demanding async function) to the concurrency manager. \ + * The time of the job's execution depends on the concurrency manager itself + * (given a generous enough `maxConcurrency` value, it might be immediate, + * but this is not guaranteed). + * @param worker Async function to be executed (job to be processed). + */ + addJob(job: () => Promise): void { + // console.debug("Adding a worker!"); + this.jobQueue.push(job); + + if (!this.maxConcurrency || this.activeWorkers < this.maxConcurrency) { + this.runNextJob(); + this.activeWorkers += 1; + } else { + // console.debug("No capacity to run a worker now, waiting!"); + } + } + + /** + * Waits until there is no running nor waiting job. \ + * If the concurrency manager is idle at the time of calling this function, + * it waits until at least one job is compeleted (can be "presubscribed"). + * @returns Promise, resolved after there is no running/waiting worker. + */ + waitForCompletion(): Promise { + return new Promise((res) => { + this.waiting.push(res); + }); + } +} \ No newline at end of file diff --git a/mx-interpreter/utils/logger.ts b/mx-interpreter/utils/logger.ts new file mode 100644 index 00000000..e57421aa --- /dev/null +++ b/mx-interpreter/utils/logger.ts @@ -0,0 +1,30 @@ +/* +* Logger class for more detailed and comprehensible logs (with colors and timestamps) +*/ + +export enum Level { + DATE = 36, + LOG = 0, + WARN = 93, + ERROR = 31, + DEBUG = 95, + RESET = 0, + } + + export default function logger( + message: string | Error, + level: (Level.LOG | Level.WARN | Level.ERROR | Level.DEBUG) = Level.LOG, + ) { + let m = message; + if (message.constructor.name.includes('Error') && typeof message !== 'string') { + m = (message).message; + } + process.stdout.write(`\x1b[${Level.DATE}m[${(new Date()).toLocaleString()}]\x1b[0m `); + process.stdout.write(`\x1b[${level}m`); + if (level === Level.ERROR || level === Level.WARN) { + process.stderr.write(m); + } else { + process.stdout.write(m); + } + process.stdout.write(`\x1b[${Level.RESET}m\n`); + } \ No newline at end of file diff --git a/mx-interpreter/utils/utils.ts b/mx-interpreter/utils/utils.ts new file mode 100644 index 00000000..48883dcf --- /dev/null +++ b/mx-interpreter/utils/utils.ts @@ -0,0 +1,13 @@ +/** + * ESLint rule in case there is only one util function + * (it still does not represent the "utils" file) +*/ + +/* eslint-disable import/prefer-default-export */ + +/** + * Converts an array of scalars to an object with **items** of the array **for keys**. + */ +export function arrayToObject(array : any[]) { + return array.reduce((p, x) => ({ ...p, [x]: [] }), {}); + } \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 00000000..5a901005 --- /dev/null +++ b/package.json @@ -0,0 +1,88 @@ +{ + "name": "maxun", + "version": "0.1.0", + "author": "Karishma Shukla", + "license": "", + "dependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/icons-material": "^5.5.1", + "@mui/lab": "^5.0.0-alpha.80", + "@mui/material": "^5.6.2", + "@testing-library/jest-dom": "^5.16.4", + "@testing-library/react": "^13.1.1", + "@testing-library/user-event": "^13.5.0", + "@types/jest": "^27.4.1", + "@types/node": "^16.11.27", + "@types/react": "^18.0.5", + "@types/react-dom": "^18.0.1", + "@types/uuid": "^8.3.4", + "@wbr-project/wbr-interpret": "^0.9.3-marketa.1", + "axios": "^0.26.0", + "buffer": "^6.0.3", + "cors": "^2.8.5", + "dotenv": "^16.0.0", + "express": "^4.17.2", + "fortawesome": "^0.0.1-security", + "joi": "^17.6.0", + "loglevel": "^1.8.0", + "loglevel-plugin-remote": "^0.6.8", + "playwright": "^1.18.1", + "prismjs": "^1.28.0", + "react": "^18.0.0", + "react-dom": "^18.0.0", + "react-highlight": "^0.14.0", + "react-scripts": "5.0.1", + "react-simple-code-editor": "^0.11.2", + "react-transition-group": "^4.4.2", + "socket.io": "^4.4.1", + "socket.io-client": "^4.4.1", + "styled-components": "^5.3.3", + "typedoc": "^0.23.8", + "typescript": "^4.6.3", + "uuid": "^8.3.2", + "uuidv4": "^6.2.12", + "web-vitals": "^2.1.4", + "winston": "^3.5.1" + }, + "scripts": { + "start": "concurrently -k \"npm run server\" \"npm run client\"", + "server": "./node_modules/.bin/nodemon server/src/server.ts", + "client": "react-app-rewired start", + "build": "react-app-rewired build", + "test": "react-app-rewired test", + "eject": "react-scripts eject", + "lint": "./node_modules/.bin/eslint ." + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@types/express": "^4.17.13", + "@types/loglevel": "^1.6.3", + "@types/node": "^17.0.15", + "@types/prismjs": "^1.26.0", + "@types/react-highlight": "^0.12.5", + "@types/react-transition-group": "^4.4.4", + "@types/styled-components": "^5.1.23", + "concurrently": "^7.0.0", + "nodemon": "^2.0.15", + "react-app-rewired": "^2.2.1", + "ts-node": "^10.4.0" + } +} diff --git a/src/components/atoms/Highlighter.tsx b/src/components/atoms/Highlighter.tsx new file mode 100644 index 00000000..b72cac06 --- /dev/null +++ b/src/components/atoms/Highlighter.tsx @@ -0,0 +1,125 @@ +import React from 'react'; + +import styled from "styled-components"; + +interface HighlighterProps { + unmodifiedRect: DOMRect; + displayedSelector: string; + width: number; + height: number; + canvasRect: DOMRect; +}; + +export const Highlighter = ({ unmodifiedRect, displayedSelector = '', width, height, canvasRect }: HighlighterProps) => { + if (!unmodifiedRect) { + return null; + } else { + // const unshiftedRect = mapRect(unmodifiedRect, width, height); + // console.log('unshiftedRect', unshiftedRect) + // const rect = { + // bottom: unshiftedRect.bottom + canvasRect.top, + // top: unshiftedRect.top + canvasRect.top, + // left: unshiftedRect.left + canvasRect.left, + // right: unshiftedRect.right + canvasRect.left, + // x: unshiftedRect.x + canvasRect.left, + // y: unshiftedRect.y + canvasRect.top, + // width: unshiftedRect.width, + // height: unshiftedRect.height, + // } + + const rect = { + top: unmodifiedRect.top + canvasRect.top, + left: unmodifiedRect.left + canvasRect.left, + right: unmodifiedRect.right + canvasRect.left, + bottom: unmodifiedRect.bottom + canvasRect.top, + width: unmodifiedRect.width, + height: unmodifiedRect.height, + }; + + const adjustedWidth = Math.min(rect.width, width - rect.left); // Adjust width if it extends beyond canvas boundary + const adjustedHeight = Math.min(rect.height, height - rect.top); // Adjust height if it extends beyond canvas boundary + + console.log('unmodifiedRect:', unmodifiedRect) + console.log('rectangle:', rect) + console.log('canvas rectangle:', canvasRect) + + // make the highlighting rectangle stay in browser window boundaries + // if (rect.bottom > canvasRect.bottom) { + // rect.height = height - unshiftedRect.top; + // } + + // if (rect.top < canvasRect.top) { + // rect.height = rect.height - (canvasRect.top - rect.top); + // rect.top = canvasRect.top; + // } + + // if (rect.right > canvasRect.right) { + // rect.width = width - unshiftedRect.left; + // } + + // if (rect.left < canvasRect.left) { + // rect.width = rect.width - (canvasRect.left - rect.left); + // rect.left = canvasRect.left; + // } + + + return ( +
+ + + {displayedSelector} + +
+ ); + } +} + +const HighlighterOutline = styled.div` + box-sizing: border-box; + pointer-events: none !important; + position: fixed !important; + background: #ff5d5b26 !important; + outline: 4px solid pink !important; + // border: 4px solid #ff5d5b !important; + z-index: 2147483647 !important; + // border-radius: 5px; + top: ${(p: HighlighterOutlineProps) => p.top}px; + left: ${(p: HighlighterOutlineProps) => p.left}px; + width: ${(p: HighlighterOutlineProps) => p.width}px; + height: ${(p: HighlighterOutlineProps) => p.height}px; +`; + +const HighlighterLabel = styled.div` + pointer-events: none !important; + position: fixed !important; + background: #080a0b !important; + color: white !important; + padding: 8px !important; + font-family: monospace !important; + border-radius: 5px !important; + z-index: 2147483647 !important; + top: ${(p: HighlighterLabelProps) => p.top}px; + left: ${(p: HighlighterLabelProps) => p.left}px; +`; + +interface HighlighterLabelProps { + top: number; + left: number; +} + +interface HighlighterOutlineProps { + top: number; + left: number; + width: number; + height: number; +} diff --git a/src/components/atoms/buttons/buttons.tsx b/src/components/atoms/buttons/buttons.tsx new file mode 100644 index 00000000..506e0de0 --- /dev/null +++ b/src/components/atoms/buttons/buttons.tsx @@ -0,0 +1,47 @@ +import styled from 'styled-components'; + +export const NavBarButton = styled.button<{ disabled: boolean }>` + margin-left: 5px; + margin-right: 5px; + padding: 0; + border: none; + background-color: transparent; + cursor: ${({ disabled }) => disabled ? 'default' : 'pointer'}; + width: 24px; + height: 24px; + border-radius: 12px; + outline: none; + color: ${({ disabled }) => disabled ? '#999' : '#333'}; + + ${({ disabled }) => disabled ? null : ` + &:hover { + background-color: #ddd; + } + &:active { + background-color: #d0d0d0; + } + `}; +`; + +export const UrlFormButton = styled.button` + position: absolute; + top: 0; + right: 0; + padding: 0; + border: none; + background-color: transparent; + cursor: pointer; + width: 24px; + height: 24px; + border-radius: 12px; + outline: none; + color: #333; + + &:hover { + background-color: #ddd; + }, + + &:active { + background-color: #d0d0d0; + }, +`; diff --git a/src/components/atoms/canvas.tsx b/src/components/atoms/canvas.tsx new file mode 100644 index 00000000..6a2f9dea --- /dev/null +++ b/src/components/atoms/canvas.tsx @@ -0,0 +1,145 @@ +import React, {useCallback, useEffect, useRef} from 'react'; +import { useSocketStore } from '../../context/socket'; +import { getMappedCoordinates } from "../../helpers/inputHelpers"; +import { useGlobalInfoStore } from "../../context/globalInfo"; + +interface CreateRefCallback { + + (ref: React.RefObject): void; + +} + +interface CanvasProps { + + width: number; + + height: number; + + onCreateRef: CreateRefCallback; + +} + +/** + * Interface for mouse's x,y coordinates + */ +export interface Coordinates { + x: number; + y: number; +}; + +const Canvas = ({ width, height, onCreateRef }: CanvasProps) => { + + const canvasRef = useRef(null); + const { socket } = useSocketStore(); + const { setLastAction, lastAction } = useGlobalInfoStore(); + + const notifyLastAction = (action: string) => { + if (lastAction !== action) { + setLastAction(action); + } + }; + + const lastMousePosition = useRef({ x: 0, y: 0 }); + //const lastWheelPosition = useRef({ deltaX: 0, deltaY: 0 }); + + const onMouseEvent = useCallback((event: MouseEvent) => { + if (socket) { + const coordinates = { + x: event.clientX, + y: event.clientY, + } + switch (event.type) { + case 'mousedown': + const clickCoordinates = getMappedCoordinates(event, canvasRef.current, width, height); + socket.emit('input:mousedown', clickCoordinates); + notifyLastAction('click'); + break; + case 'mousemove': + const coordinates = getMappedCoordinates(event, canvasRef.current, width, height); + if (lastMousePosition.current.x !== coordinates.x || + lastMousePosition.current.y !== coordinates.y) { + lastMousePosition.current = { + x: coordinates.x, + y: coordinates.y, + }; + socket.emit('input:mousemove', { + x: coordinates.x, + y: coordinates.y, + }); + notifyLastAction('move'); + } + break; + case 'wheel': + const wheelEvent = event as WheelEvent; + const deltas = { + deltaX: Math.round(wheelEvent.deltaX), + deltaY: Math.round(wheelEvent.deltaY), + }; + socket.emit('input:wheel', deltas); + notifyLastAction('scroll'); + break; + default: + console.log('Default mouseEvent registered'); + return; + } + } + }, [socket]); + + const onKeyboardEvent = useCallback((event: KeyboardEvent) => { + if (socket) { + switch (event.type) { + case 'keydown': + socket.emit('input:keydown', { key: event.key, coordinates: lastMousePosition.current }); + notifyLastAction(`${event.key} pressed`); + break; + case 'keyup': + socket.emit('input:keyup', event.key); + break; + default: + console.log('Default keyEvent registered'); + return; + } + } + }, [socket]); + + + useEffect(() => { + if (canvasRef.current) { + onCreateRef(canvasRef); + canvasRef.current.addEventListener('mousedown', onMouseEvent); + canvasRef.current.addEventListener('mousemove', onMouseEvent); + canvasRef.current.addEventListener('wheel', onMouseEvent, { passive: true }); + canvasRef.current.addEventListener('keydown', onKeyboardEvent); + canvasRef.current.addEventListener('keyup', onKeyboardEvent); + + return () => { + if (canvasRef.current) { + canvasRef.current.removeEventListener('mousedown', onMouseEvent); + canvasRef.current.removeEventListener('mousemove', onMouseEvent); + canvasRef.current.removeEventListener('wheel', onMouseEvent); + canvasRef.current.removeEventListener('keydown', onKeyboardEvent); + canvasRef.current.removeEventListener('keyup', onKeyboardEvent); + } + + }; + }else { + console.log('Canvas not initialized'); + } + + }, [onMouseEvent]); + + return ( + // + + ); + +}; + + +export default Canvas; diff --git a/src/components/atoms/form.tsx b/src/components/atoms/form.tsx new file mode 100644 index 00000000..56a105b3 --- /dev/null +++ b/src/components/atoms/form.tsx @@ -0,0 +1,19 @@ +import styled from 'styled-components'; + +export const NavBarForm = styled.form` + flex: 1px; + margin-left: 5px; + margin-right: 5px; + position: relative; +`; + +export const NavBarInput = styled.input` + box-sizing: border-box; + outline: none; + width: 100%; + height: 24px; + border-radius: 12px; + border: none; + padding-left: 12px; + padding-right: 40px; +`; diff --git a/src/components/molecules/BrowserNavBar.tsx b/src/components/molecules/BrowserNavBar.tsx new file mode 100644 index 00000000..e57e7c21 --- /dev/null +++ b/src/components/molecules/BrowserNavBar.tsx @@ -0,0 +1,123 @@ +import type { + FC, +} from 'react'; +import styled from 'styled-components'; + +import ReplayIcon from '@mui/icons-material/Replay'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; + +import { NavBarButton } from '../atoms/buttons/buttons'; +import { UrlForm } from './UrlForm'; +import { useCallback, useEffect, useState } from "react"; +import {useSocketStore} from "../../context/socket"; +import { getCurrentUrl } from "../../api/recording"; + +const StyledNavBar = styled.div<{ browserWidth: number }>` + display: flex; + padding: 5px; + background-color: #f6f6f6; + width: ${({ browserWidth }) => browserWidth}px; +`; + +interface NavBarProps { + browserWidth: number; + handleUrlChanged: (url: string) => void; +}; + +const BrowserNavBar: FC = ({ + browserWidth, + handleUrlChanged, +}) => { + + // context: + const { socket } = useSocketStore(); + + const [currentUrl, setCurrentUrl] = useState('https://'); + + const handleRefresh = useCallback(() : void => { + socket?.emit('input:refresh'); + }, [socket]); + + const handleGoTo = useCallback((address: string) : void => { + socket?.emit('input:url', address); + }, [socket]); + + const handleCurrentUrlChange = useCallback((url: string) => { + handleUrlChanged(url); + setCurrentUrl(url); + }, [handleUrlChanged, currentUrl]); + + useEffect(() => { + getCurrentUrl().then((response) => { + if (response) { + handleUrlChanged(response); + setCurrentUrl(response); + } + }).catch((error) => { + console.log("Fetching current url failed"); + }) + }, []); + + useEffect(() => { + if (socket) { + socket.on('urlChanged', handleCurrentUrlChange); + } + return () => { + if (socket) { + socket.off('urlChanged', handleCurrentUrlChange); + } + } + }, [socket, handleCurrentUrlChange]) + + const addAddress = (address: string) => { + if (socket) { + handleUrlChanged(address); + handleGoTo(address); + } + }; + + return ( + + { + socket?.emit('input:back'); + }} + disabled={false} + > + + + + { + socket?.emit('input:forward'); + }} + disabled={false} + > + + + + { + if (socket) { + handleRefresh() + } + }} + disabled={false} + > + + + + + + ); +} + +export default BrowserNavBar; diff --git a/src/components/molecules/BrowserTabs.tsx b/src/components/molecules/BrowserTabs.tsx new file mode 100644 index 00000000..2cda3937 --- /dev/null +++ b/src/components/molecules/BrowserTabs.tsx @@ -0,0 +1,93 @@ +import * as React from 'react'; +import { Box, IconButton, Tab, Tabs } from "@mui/material"; +import { AddButton } from "../atoms/buttons/AddButton"; +import { useBrowserDimensionsStore } from "../../context/browserDimensions"; +import { Close } from "@mui/icons-material"; + +interface BrowserTabsProp { + tabs: string[], + handleTabChange: (index: number) => void, + handleAddNewTab: () => void, + handleCloseTab: (index: number) => void, + handleChangeIndex: (index: number) => void; + tabIndex: number +} + +export const BrowserTabs = ( + { + tabs, handleTabChange, handleAddNewTab, + handleCloseTab, handleChangeIndex, tabIndex + }: BrowserTabsProp) => { + + let tabWasClosed = false; + + const { width } = useBrowserDimensionsStore(); + + const handleChange = (event: React.SyntheticEvent, newValue: number) => { + if (!tabWasClosed) { + handleChangeIndex(newValue); + } + }; + + return ( + + + + {tabs.map((tab, index) => { + return ( + { + tabWasClosed = true; + handleCloseTab(index); + }} disabled={tabs.length === 1} + />} + iconPosition="end" + onClick={() => { + if (!tabWasClosed) { + handleTabChange(index) + } + } + } + label={tab} + /> + ); + })} + + + + + ); +} + +interface CloseButtonProps { + closeTab: () => void; + disabled: boolean; +} + +const CloseButton = ({ closeTab, disabled }: CloseButtonProps) => { + return ( + + + + ); +} diff --git a/src/components/molecules/UrlForm.tsx b/src/components/molecules/UrlForm.tsx new file mode 100644 index 00000000..a6681ab9 --- /dev/null +++ b/src/components/molecules/UrlForm.tsx @@ -0,0 +1,73 @@ +import { useState, useCallback, useEffect, } from 'react'; +import type { SyntheticEvent, } from 'react'; +import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'; + +import { NavBarForm, NavBarInput } from "../atoms/form"; +import { UrlFormButton } from "../atoms/buttons/buttons"; +import { useSocketStore } from '../../context/socket'; +import { Socket } from "socket.io-client"; + +type Props = { + currentAddress: string; + handleRefresh: (socket: Socket) => void; + setCurrentAddress: (address: string) => void; +}; + +export const UrlForm = ({ + currentAddress, + handleRefresh, + setCurrentAddress, +}: Props) => { + // states: + const [address, setAddress] = useState(currentAddress); + // context: + const { socket } = useSocketStore(); + + const areSameAddresses = address === currentAddress; + + const onChange = useCallback((event: SyntheticEvent): void => { + setAddress((event.target as HTMLInputElement).value); + }, [address]); + + const onSubmit = (event: SyntheticEvent): void => { + event.preventDefault(); + let url = address; + + // add protocol if missing + if (!/^(?:f|ht)tps?\:\/\//.test(address)) { + url = "https://" + address; + setAddress(url); + } + + if (areSameAddresses) { + if (socket) { + handleRefresh(socket); + } + } else { + try { + // try the validity of url + new URL(url); + setCurrentAddress(url); + } catch (e) { + alert(`ERROR: ${url} is not a valid url!`); + } + } + }; + + useEffect(() => { + setAddress(currentAddress) + }, [currentAddress]); + + return ( + + + + + + + ); +}; diff --git a/src/components/organisms/BrowserContent.tsx b/src/components/organisms/BrowserContent.tsx new file mode 100644 index 00000000..189af6ee --- /dev/null +++ b/src/components/organisms/BrowserContent.tsx @@ -0,0 +1,136 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import styled from "styled-components"; +import BrowserNavBar from "../molecules/BrowserNavBar"; +import { BrowserWindow } from "./BrowserWindow"; +import { useBrowserDimensionsStore } from "../../context/browserDimensions"; +import { BrowserTabs } from "../molecules/BrowserTabs"; +import { useSocketStore } from "../../context/socket"; +import { getCurrentTabs, getCurrentUrl, interpretCurrentRecording } from "../../api/recording"; + +export const BrowserContent = () => { + const { width } = useBrowserDimensionsStore(); + const { socket } = useSocketStore(); + + const [tabs, setTabs] = useState(['current']); + const [tabIndex, setTabIndex] = React.useState(0); + + const handleChangeIndex = useCallback((index: number) => { + setTabIndex(index); + }, [tabIndex]) + + const handleCloseTab = useCallback((index: number) => { + // the tab needs to be closed on the backend + socket?.emit('closeTab', { + index, + isCurrent: tabIndex === index, + }); + // change the current index as current tab gets closed + if (tabIndex === index) { + if (tabs.length > index + 1) { + handleChangeIndex(index); + } else { + handleChangeIndex(index - 1); + } + } else { + handleChangeIndex(tabIndex - 1); + } + // update client tabs + setTabs((prevState) => [ + ...prevState.slice(0, index), + ...prevState.slice(index + 1) + ]) + }, [tabs, socket, tabIndex]); + + const handleAddNewTab = useCallback(() => { + // Adds new tab by pressing the plus button + socket?.emit('addTab'); + // Adds a new tab to the end of the tabs array and shifts focus + setTabs((prevState) => [...prevState, 'new tab']); + handleChangeIndex(tabs.length); + }, [socket, tabs]); + + const handleNewTab = useCallback((tab: string) => { + // Adds a new tab to the end of the tabs array and shifts focus + setTabs((prevState) => [...prevState, tab]); + // changes focus on the new tab - same happens in the remote browser + handleChangeIndex(tabs.length); + handleTabChange(tabs.length); + }, [tabs]); + + const handleTabChange = useCallback((index: number) => { + // page screencast and focus needs to be changed on backend + socket?.emit('changeTab', index); + }, [socket]); + + const handleUrlChanged = (url: string) => { + const parsedUrl = new URL(url); + if (parsedUrl.hostname) { + const host = parsedUrl.hostname.match(/\b(?!www\.)[a-zA-Z0-9]+/g)?.join('.') + if (host && host !== tabs[tabIndex]) { + setTabs((prevState) => [ + ...prevState.slice(0, tabIndex), + host, + ...prevState.slice(tabIndex + 1) + ]) + } + } else { + if (tabs[tabIndex] !== 'new tab') { + setTabs((prevState) => [ + ...prevState.slice(0, tabIndex), + 'new tab', + ...prevState.slice(tabIndex + 1) + ]) + } + } + + }; + + const tabHasBeenClosedHandler = useCallback((index: number) => { + handleCloseTab(index); + }, [handleCloseTab]) + + useEffect(() => { + if (socket) { + socket.on('newTab', handleNewTab); + socket.on('tabHasBeenClosed', tabHasBeenClosedHandler); + } + return () => { + if (socket) { + socket.off('newTab', handleNewTab); + socket.off('tabHasBeenClosed', tabHasBeenClosedHandler); + } + } + }, [socket, handleNewTab]) + + useEffect(() => { + getCurrentTabs().then((response) => { + if (response) { + setTabs(response); + } + }).catch((error) => { + console.log("Fetching current url failed"); + }) + }, []) + + return ( + + + + + + ); +} + +const BrowserContentWrapper = styled.div` + grid-area: browser; +`; diff --git a/src/components/organisms/BrowserWindow.tsx b/src/components/organisms/BrowserWindow.tsx new file mode 100644 index 00000000..32ab9607 --- /dev/null +++ b/src/components/organisms/BrowserWindow.tsx @@ -0,0 +1,106 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useSocketStore } from '../../context/socket'; +import Canvas from "../atoms/canvas"; +import { useBrowserDimensionsStore } from "../../context/browserDimensions"; +import { Highlighter } from "../atoms/Highlighter"; + +export const BrowserWindow = () => { + + const [canvasRef, setCanvasReference] = useState | undefined>(undefined); + const [screenShot, setScreenShot] = useState(""); + const [highlighterData, setHighlighterData] = useState<{ rect: DOMRect, selector: string } | null>(null); + + const { socket } = useSocketStore(); + const { width, height } = useBrowserDimensionsStore(); + + console.log('Use browser dimensions:', width, height) + + const onMouseMove = (e: MouseEvent) => { + if (canvasRef && canvasRef.current && highlighterData) { + const canvasRect = canvasRef.current.getBoundingClientRect(); + // mousemove outside the browser window + if ( + e.pageX < canvasRect.left + || e.pageX > canvasRect.right + || e.pageY < canvasRect.top + || e.pageY > canvasRect.bottom + ) { + setHighlighterData(null); + } + } + }; + + const screencastHandler = useCallback((data: string) => { + setScreenShot(data); + }, [screenShot]); + + useEffect(() => { + if (socket) { + socket.on("screencast", screencastHandler); + } + if (canvasRef?.current) { + drawImage(screenShot, canvasRef.current); + } else { + console.log('Canvas is not initialized'); + } + return () => { + socket?.off("screencast", screencastHandler); + } + + }, [screenShot, canvasRef, socket, screencastHandler]); + + + const highlighterHandler = useCallback((data: { rect: DOMRect, selector: string }) => { + setHighlighterData(data); + console.log('Highlighter Rect via socket:', data.rect) + }, [highlighterData]) + + useEffect(() => { + document.addEventListener('mousemove', onMouseMove, false); + if (socket) { + socket.on("highlighter", highlighterHandler); + } + //cleaning function + return () => { + document.removeEventListener('mousemove', onMouseMove); + socket?.off("highlighter", highlighterHandler); + }; + }, [socket, onMouseMove]); + + return ( + <> + {(highlighterData?.rect != null && highlighterData?.rect.top != null) && canvasRef?.current ? + < Highlighter + unmodifiedRect={highlighterData?.rect} + displayedSelector={highlighterData?.selector} + width={width} + height={height} + canvasRect={canvasRef.current.getBoundingClientRect()} + /> + : null} + + + ); +}; + +const drawImage = (image: string, canvas: HTMLCanvasElement): void => { + + const ctx = canvas.getContext('2d'); + + const img = new Image(); + + img.src = image; + img.onload = () => { + URL.revokeObjectURL(img.src); + //ctx?.clearRect(0, 0, canvas?.width || 0, VIEWPORT_H || 0); + // ctx?.drawImage(img, 0, 0, canvas.width , canvas.height); + ctx?.drawImage(img, 0, 0, 1280, 720); // Explicitly draw image at 1280 x 720 + console.log('Image drawn on canvas:', img.width, img.height); + console.log('Image drawn on canvas:', canvas.width, canvas.height); + }; + +}; diff --git a/src/context/browserDimensions.tsx b/src/context/browserDimensions.tsx new file mode 100644 index 00000000..e1d81681 --- /dev/null +++ b/src/context/browserDimensions.tsx @@ -0,0 +1,39 @@ +import React, { createContext, useCallback, useContext, useState } from "react"; + +interface BrowserDimensions { + width: number; + height: number; + setWidth: (newWidth: number) => void; +}; + +class BrowserDimensionsStore implements Partial{ + width: number = 1280; + height: number = 720; +}; + +const browserDimensionsStore = new BrowserDimensionsStore(); +const browserDimensionsContext = createContext(browserDimensionsStore as BrowserDimensions); + +export const useBrowserDimensionsStore = () => useContext(browserDimensionsContext); + +export const BrowserDimensionsProvider = ({ children }: { children: JSX.Element }) => { + const [width, setWidth] = useState(browserDimensionsStore.width); + const [height, setHeight] = useState(browserDimensionsStore.height); + + const setNewWidth = useCallback((newWidth: number) => { + setWidth(newWidth); + setHeight(Math.round(newWidth / 1.6)); + }, [setWidth, setHeight]); + + return ( + + {children} + + ); +}; diff --git a/src/helpers/inputHelpers.ts b/src/helpers/inputHelpers.ts new file mode 100644 index 00000000..d7861734 --- /dev/null +++ b/src/helpers/inputHelpers.ts @@ -0,0 +1,141 @@ +import { + ONE_PERCENT_OF_VIEWPORT_H, + ONE_PERCENT_OF_VIEWPORT_W, + VIEWPORT_W, + VIEWPORT_H, +} from "../constants/const"; +import { Coordinates } from '../components/atoms/canvas'; + +export const throttle = (callback: any, limit: number) => { + let wait = false; + return (...args: any[]) => { + if (!wait) { + callback(...args); + wait = true; + setTimeout(function () { + wait = false; + }, limit); + } + } +} + +export const getMappedCoordinates = ( + event: MouseEvent, + canvas: HTMLCanvasElement | null, + browserWidth: number, + browserHeight: number, +): Coordinates => { + const clientCoordinates = getCoordinates(event, canvas); + const mappedX = mapPixelFromSmallerToLarger( + browserWidth / 100, + ONE_PERCENT_OF_VIEWPORT_W, + clientCoordinates.x, + ); + const mappedY = mapPixelFromSmallerToLarger( + browserHeight / 100, + ONE_PERCENT_OF_VIEWPORT_H, + clientCoordinates.y, + ); + + return { + x: mappedX, + y: mappedY + }; +}; + +const getCoordinates = (event: MouseEvent, canvas: HTMLCanvasElement | null): Coordinates => { + if (!canvas) { + return { x: 0, y: 0 }; + } + return { + x: event.pageX - canvas.offsetLeft, + y: event.pageY - canvas.offsetTop + }; +}; + +export const mapRect = ( + rect: DOMRect, + browserWidth: number, + browserHeight: number, +) => { + const mappedX = mapPixelFromSmallerToLarger( + browserWidth / 100, + ONE_PERCENT_OF_VIEWPORT_W, + rect.x, + ); + const mappedLeft = mapPixelFromSmallerToLarger( + browserWidth / 100, + ONE_PERCENT_OF_VIEWPORT_W, + rect.left, + ); + const mappedRight = mapPixelFromSmallerToLarger( + browserWidth / 100, + ONE_PERCENT_OF_VIEWPORT_W, + rect.right, + ); + const mappedWidth = mapPixelFromSmallerToLarger( + browserWidth / 100, + ONE_PERCENT_OF_VIEWPORT_W, + rect.width, + ); + const mappedY = mapPixelFromSmallerToLarger( + browserHeight / 100, + ONE_PERCENT_OF_VIEWPORT_H, + rect.y, + ); + const mappedTop = mapPixelFromSmallerToLarger( + browserHeight / 100, + ONE_PERCENT_OF_VIEWPORT_H, + rect.top, + ); + const mappedBottom = mapPixelFromSmallerToLarger( + browserHeight / 100, + ONE_PERCENT_OF_VIEWPORT_H, + rect.bottom, + ); + const mappedHeight = mapPixelFromSmallerToLarger( + browserHeight / 100, + ONE_PERCENT_OF_VIEWPORT_H, + rect.height, + ); + + console.log('Mapped:', { + x: mappedX, + y: mappedY, + width: mappedWidth, + height: mappedHeight, + top: mappedTop, + right: mappedRight, + bottom: mappedBottom, + left: mappedLeft, + }) + + return { + x: mappedX, + y: mappedY, + width: mappedWidth, + height: mappedHeight, + top: mappedTop, + right: mappedRight, + bottom: mappedBottom, + left: mappedLeft, + }; +}; + +const mapPixelFromSmallerToLarger = ( + onePercentOfSmallerScreen: number, + onePercentOfLargerScreen: number, + pixel: number +): number => { + const xPercentOfScreen = pixel / onePercentOfSmallerScreen; + return xPercentOfScreen * onePercentOfLargerScreen; +}; + +const mapPixelFromLargerToSmaller = ( + onePercentOfSmallerScreen: number, + onePercentOfLargerScreen: number, + pixel: number +): number => { + const xPercentOfScreen = pixel / onePercentOfLargerScreen; + return Math.round(xPercentOfScreen * onePercentOfSmallerScreen); +}; diff --git a/src/pages/RecordingPage.tsx b/src/pages/RecordingPage.tsx new file mode 100644 index 00000000..7a2f03a8 --- /dev/null +++ b/src/pages/RecordingPage.tsx @@ -0,0 +1,127 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { Grid } from '@mui/material'; +import { BrowserContent } from "../components/organisms/BrowserContent"; +import { startRecording, getActiveBrowserId } from "../api/recording"; +import { LeftSidePanel } from "../components/organisms/LeftSidePanel"; +import { RightSidePanel } from "../components/organisms/RightSidePanel"; +import { Loader } from "../components/atoms/Loader"; +import { useSocketStore } from "../context/socket"; +import { useBrowserDimensionsStore } from "../context/browserDimensions"; +import { useGlobalInfoStore } from "../context/globalInfo"; +import { editRecordingFromStorage } from "../api/storage"; +import { WhereWhatPair } from "@wbr-project/wbr-interpret"; + +interface RecordingPageProps { + recordingName?: string; +} + +export interface PairForEdit { + pair: WhereWhatPair | null, + index: number, +} + +export const RecordingPage = ({ recordingName }: RecordingPageProps) => { + + const [isLoaded, setIsLoaded] = React.useState(false); + const [hasScrollbar, setHasScrollbar] = React.useState(false); + const [pairForEdit, setPairForEdit] = useState({ + pair: null, + index: 0, + }); + + const browserContentRef = React.useRef(null); + const workflowListRef = React.useRef(null); + + const { setId, socket } = useSocketStore(); + const { setWidth } = useBrowserDimensionsStore(); + const { browserId, setBrowserId } = useGlobalInfoStore(); + + const handleSelectPairForEdit = (pair: WhereWhatPair, index: number) => { + setPairForEdit({ + pair, + index, + }); + }; + + //resize browser content when loaded event is fired + useEffect(() => changeBrowserDimensions(), [isLoaded]) + + useEffect(() => { + let isCancelled = false; + const handleRecording = async () => { + const id = await getActiveBrowserId(); + if (!isCancelled) { + if (id) { + setId(id); + setBrowserId(id); + setIsLoaded(true); + } else { + const newId = await startRecording() + setId(newId); + setBrowserId(newId); + } + } + }; + + handleRecording(); + + return () => { + isCancelled = true; + } + }, [setId]); + + const changeBrowserDimensions = useCallback(() => { + if (browserContentRef.current) { + const currentWidth = Math.floor(browserContentRef.current.getBoundingClientRect().width); + const innerHeightWithoutNavBar = window.innerHeight - 54.5; + if (innerHeightWithoutNavBar <= (currentWidth / 1.6)) { + setWidth(currentWidth - 10); + setHasScrollbar(true); + } else { + setWidth(currentWidth); + } + socket?.emit("rerender"); + } + }, [socket]); + + const handleLoaded = useCallback(() => { + if (recordingName && browserId) { + editRecordingFromStorage(browserId, recordingName).then(() => setIsLoaded(true)); + } else { + if (browserId === 'new-recording') { + socket?.emit('new-recording'); + } + setIsLoaded(true); + } + }, [socket, browserId, recordingName, isLoaded]) + + useEffect(() => { + socket?.on('loaded', handleLoaded); + return () => { + socket?.off('loaded', handleLoaded) + } + }, [socket, handleLoaded]); + + return ( +
+ {isLoaded ? + + {/* + + */} + + + + {/* + + */} + + : } +
+ ); +};