Merge pull request #13 from amhsirak/develop

feat: browser recording
This commit is contained in:
Karishma Shukla
2024-06-14 23:20:42 +05:30
committed by GitHub
17 changed files with 1398 additions and 0 deletions

8
mx-interpreter/index.ts Normal file
View File

@@ -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';

View File

@@ -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<any>): 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<void> {
return new Promise((res) => {
this.waiting.push(res);
});
}
}

View File

@@ -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 = <Error><unknown>(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(<string>m);
} else {
process.stdout.write(<string>m);
}
process.stdout.write(`\x1b[${Level.RESET}m\n`);
}

View File

@@ -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]: [] }), {});
}

88
package.json Normal file
View File

@@ -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"
}
}

View File

@@ -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 (
<div>
<HighlighterOutline
id="Highlighter-outline"
top={rect.top}
left={rect.left}
width={rect.width}
height={rect.height}
/>
<HighlighterLabel
id="Highlighter-label"
top={rect.top + rect.height + 8}
left={rect.left}
>
{displayedSelector}
</HighlighterLabel>
</div>
);
}
}
const HighlighterOutline = styled.div<HighlighterOutlineProps>`
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<HighlighterLabelProps>`
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;
}

View File

@@ -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;
},
`;

View File

@@ -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<HTMLCanvasElement>): 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<HTMLCanvasElement>(null);
const { socket } = useSocketStore();
const { setLastAction, lastAction } = useGlobalInfoStore();
const notifyLastAction = (action: string) => {
if (lastAction !== action) {
setLastAction(action);
}
};
const lastMousePosition = useRef<Coordinates>({ x: 0, y: 0 });
//const lastWheelPosition = useRef<ScrollDeltas>({ 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 (
// <canvas tabIndex={0} ref={canvasRef} height={height} width={width} />
<canvas
tabIndex={0}
ref={canvasRef}
height={720}
width={1280}
style={{ width: '1280px', height: '720px' }} // Ensure dimensions are explicitly set
/>
);
};
export default Canvas;

View File

@@ -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;
`;

View File

@@ -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<NavBarProps> = ({
browserWidth,
handleUrlChanged,
}) => {
// context:
const { socket } = useSocketStore();
const [currentUrl, setCurrentUrl] = useState<string>('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 (
<StyledNavBar browserWidth={browserWidth}>
<NavBarButton
type="button"
onClick={() => {
socket?.emit('input:back');
}}
disabled={false}
>
<ArrowBackIcon/>
</NavBarButton>
<NavBarButton
type="button"
onClick={()=>{
socket?.emit('input:forward');
}}
disabled={false}
>
<ArrowForwardIcon/>
</NavBarButton>
<NavBarButton
type="button"
onClick={() => {
if (socket) {
handleRefresh()
}
}}
disabled={false}
>
<ReplayIcon/>
</NavBarButton>
<UrlForm
currentAddress={currentUrl}
handleRefresh={handleRefresh}
setCurrentAddress={addAddress}
/>
</StyledNavBar>
);
}
export default BrowserNavBar;

View File

@@ -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 (
<Box sx={{
width: `${width}px`,
display: 'flex',
overflow: 'auto',
alignItems: 'center',
}}>
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs
value={tabIndex}
onChange={handleChange}
>
{tabs.map((tab, index) => {
return (
<Tab
key={`tab-${index}`}
id={`tab-${index}`}
icon={<CloseButton closeTab={() => {
tabWasClosed = true;
handleCloseTab(index);
}} disabled={tabs.length === 1}
/>}
iconPosition="end"
onClick={() => {
if (!tabWasClosed) {
handleTabChange(index)
}
}
}
label={tab}
/>
);
})}
</Tabs>
</Box>
<AddButton handleClick={handleAddNewTab} />
</Box>
);
}
interface CloseButtonProps {
closeTab: () => void;
disabled: boolean;
}
const CloseButton = ({ closeTab, disabled }: CloseButtonProps) => {
return (
<IconButton
aria-label="close"
size={"small"}
onClick={closeTab}
disabled={disabled}
sx={{
height: '34px',
'&:hover': { color: 'white', backgroundColor: '#1976d2' }
}}
component="span"
>
<Close />
</IconButton>
);
}

View File

@@ -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<string>(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 (
<NavBarForm onSubmit={onSubmit}>
<NavBarInput
type="text"
value={address}
onChange={onChange}
/>
<UrlFormButton type="submit">
<KeyboardArrowRightIcon />
</UrlFormButton>
</NavBarForm>
);
};

View File

@@ -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<string[]>(['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 (
<BrowserContentWrapper>
<BrowserTabs
tabs={tabs}
handleTabChange={handleTabChange}
handleAddNewTab={handleAddNewTab}
handleCloseTab={handleCloseTab}
handleChangeIndex={handleChangeIndex}
tabIndex={tabIndex}
/>
<BrowserNavBar
browserWidth={width - 10}
handleUrlChanged={handleUrlChanged}
/>
<BrowserWindow/>
</BrowserContentWrapper>
);
}
const BrowserContentWrapper = styled.div`
grid-area: browser;
`;

View File

@@ -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<React.RefObject<HTMLCanvasElement> | undefined>(undefined);
const [screenShot, setScreenShot] = useState<string>("");
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}
<Canvas
onCreateRef={setCanvasReference}
width={width}
height={height}
/>
</>
);
};
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);
};
};

View File

@@ -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<BrowserDimensions>{
width: number = 1280;
height: number = 720;
};
const browserDimensionsStore = new BrowserDimensionsStore();
const browserDimensionsContext = createContext<BrowserDimensions>(browserDimensionsStore as BrowserDimensions);
export const useBrowserDimensionsStore = () => useContext(browserDimensionsContext);
export const BrowserDimensionsProvider = ({ children }: { children: JSX.Element }) => {
const [width, setWidth] = useState<number>(browserDimensionsStore.width);
const [height, setHeight] = useState<number>(browserDimensionsStore.height);
const setNewWidth = useCallback((newWidth: number) => {
setWidth(newWidth);
setHeight(Math.round(newWidth / 1.6));
}, [setWidth, setHeight]);
return (
<browserDimensionsContext.Provider
value={{
width,
height,
setWidth: setNewWidth,
}}
>
{children}
</browserDimensionsContext.Provider>
);
};

141
src/helpers/inputHelpers.ts Normal file
View File

@@ -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);
};

127
src/pages/RecordingPage.tsx Normal file
View File

@@ -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<PairForEdit>({
pair: null,
index: 0,
});
const browserContentRef = React.useRef<HTMLDivElement>(null);
const workflowListRef = React.useRef<HTMLDivElement>(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 (
<div>
{isLoaded ?
<Grid container direction="row" spacing={0}>
{/* <Grid item xs={2} ref={workflowListRef} style={{ display: "flex", flexDirection: "row" }}>
<LeftSidePanel
sidePanelRef={workflowListRef.current}
alreadyHasScrollbar={hasScrollbar}
recordingName={recordingName ? recordingName : ''}
handleSelectPairForEdit={handleSelectPairForEdit}
/>
</Grid> */}
<Grid id="browser-content" ref={browserContentRef} item xs>
<BrowserContent />
</Grid>
{/* <Grid item xs={2}>
<RightSidePanel pairForEdit={pairForEdit} changeBrowserDimensions={changeBrowserDimensions}/>
</Grid> */}
</Grid>
: <Loader />}
</div>
);
};