init
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "browseruse-front",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@ant-design/x": "^1.2.0",
|
||||
"@types/react": "^19.1.4",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"sass": "^1.89.2",
|
||||
"sass-loader": "^16.0.5",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<meta name="theme-color" content="#000000"/>
|
||||
<meta
|
||||
name="description"
|
||||
content="browser-use-demo"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png"/>
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json"/>
|
||||
<title>Browser-use Demo</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 257 KiB |
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"short_name": "browser-use-demo",
|
||||
"name": "browser-use-demo",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" shape-rendering="auto">
|
||||
<metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:dcterms="http://purl.org/dc/terms/">
|
||||
<rdf:RDF>
|
||||
<rdf:Description>
|
||||
<dc:title>Miniavs - Free Avatar Creator</dc:title>
|
||||
<dc:creator>Webpixels</dc:creator>
|
||||
<dc:source xsi:type="dcterms:URI">https://www.figma.com/community/file/923211396597067458</dc:source>
|
||||
<dcterms:license xsi:type="dcterms:URI">https://creativecommons.org/licenses/by/4.0/</dcterms:license>
|
||||
<dc:rights>Remix of „Miniavs - Free Avatar Creator”
|
||||
(https://www.figma.com/community/file/923211396597067458) by „Webpixels”, licensed under „CC BY 4.0”
|
||||
(https://creativecommons.org/licenses/by/4.0/)
|
||||
</dc:rights>
|
||||
</rdf:Description>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<mask id="viewboxMask">
|
||||
<rect width="64" height="64" rx="0" ry="0" x="0" y="0" fill="#fff"/>
|
||||
</mask>
|
||||
<g mask="url(#viewboxMask)">
|
||||
<path d="M45.89 36.1c0 8.5-1.26 18.86-10.89 19.82v9.95S31.36 68 26.5 68c-4.86 0-8.5-3.48-8.5-3.48V42a5 5 0 0 1-1.3-9.83C15.36 22.64 17.5 13 32 13c14.59 0 14.24 11.08 13.96 19.81-.04 1.15-.07 2.25-.07 3.29Z"
|
||||
fill="#ffcb7e"/>
|
||||
<path d="M35 55.92c-.48.05-.98.07-1.5.07-8.88 0-13.9-7.15-15.5-14.6v23.13S21.64 68 26.5 68c4.86 0 8.5-2.13 8.5-2.13v-9.95Z"
|
||||
fill="#000" fill-opacity=".07"/>
|
||||
<path d="M34.63 55.95c-.37.03-.74.04-1.13.04-6.53 0-10.97-3.86-13.5-8.87V48.24c0 5.38 2.61 9.75 8.28 9.75h1.35c3.34.03 4.59.04 5-2.04ZM16.7 32.17A5 5 0 0 0 18.14 42c-.48-1.98-.71-3.99-.71-5.9a46.7 46.7 0 0 1-.73-3.93Z"
|
||||
fill="#000" fill-opacity=".07"/>
|
||||
<rect x="36" y="41" width="3" height="2" rx="1" fill="#000" fill-opacity=".07"/>
|
||||
<rect x="7" y="60" width="40" height="23" rx="9" fill="#ff4dd8"/>
|
||||
<path d="M22 28c-.63 3 1 6.98 1 7.74 0 .77-3.93 3.03-5 3.76-1.07.73-1.5-7-1.5-7-3 0-3.5 5.5-3.5 5.5s-2.25-.74-3-4.5c-.51-2.54.3-8.09.5-9.5.5-3.5 1-11.5 7.5-15.5s23-4 27-3C54.9 7.97 56.22 21.5 53 26c-5 5.5-19-1-23.5-1s-6.87 0-7.5 3Z"
|
||||
fill="#47280b"/>
|
||||
<g transform="translate(1)">
|
||||
<path d="M27.93 46a1 1 0 0 1 1-1h9.14a1 1 0 0 1 1 1 5 5 0 0 1-5 5h-1.14a5 5 0 0 1-5-5Z" fill="#66253C"/>
|
||||
<path d="M35.76 50.7a5 5 0 0 1-1.69.3h-1.14a5 5 0 0 1-5-4.8c.77-.29 1.9-.25 3.02-.22L32 46c2.21 0 4 1.57 4 3.5 0 .42-.09.83-.24 1.2Z"
|
||||
fill="#B03E67"/>
|
||||
<path d="M29 45h10v1a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1v-1Z" fill="#fff"/>
|
||||
<path d="M31 45.3c0-.17.13-.3.3-.3h1.4c.17 0 .3.13.3.3v2.4a.3.3 0 0 1-.3.3h-1.4a.3.3 0 0 1-.3-.3v-2.4Z"
|
||||
fill="#B03E67"/>
|
||||
</g>
|
||||
<g transform="translate(0 -1)">
|
||||
<path d="M30 37.5a1.5 1.5 0 0 1 3 0v1.23c0 .15-.12.27-.27.27h-2.46a.27.27 0 0 1-.27-.27V37.5ZM40 37.5a1.5 1.5 0 0 1 3 0v1.23c0 .15-.12.27-.27.27h-2.46a.27.27 0 0 1-.27-.27V37.5Z"
|
||||
fill="#1B0B47"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,38 @@
|
||||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
267
browser_use/browser_use_fullstack_runtime/frontend/src/App.tsx
Normal file
267
browser_use/browser_use_fullstack_runtime/frontend/src/App.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import React, { useState, useRef, useEffect } from "react"; // 添加 useEffect
|
||||
import { Layout, theme } from "antd";
|
||||
|
||||
import { Input, List } from "antd";
|
||||
import type { InputRef } from "antd";
|
||||
|
||||
import { Image, Avatar, Spin } from "antd";
|
||||
import { Flex } from "antd";
|
||||
import Browser from "./Browser";
|
||||
|
||||
const { Content, Footer } = Layout;
|
||||
|
||||
const REACT_APP_API_URL =
|
||||
process.env.REACT_APP_API_URL || "http://localhost:9000";
|
||||
const BACKEND_URL = REACT_APP_API_URL + "/v1/chat/completions";
|
||||
const BACKEND_WS_URL = REACT_APP_API_URL + "/env_info";
|
||||
const DEFAULT_MODEL = "qwen-max";
|
||||
const systemMessage = {
|
||||
role: "system",
|
||||
content: "You are a helpful assistant.",
|
||||
};
|
||||
|
||||
type SiteItem = {
|
||||
title: string;
|
||||
url: string;
|
||||
favicon: string;
|
||||
description: string;
|
||||
};
|
||||
type ChatMessage = {
|
||||
message: string;
|
||||
think: string;
|
||||
sender: string;
|
||||
site: SiteItem[];
|
||||
}[];
|
||||
const { Search } = Input;
|
||||
|
||||
const App: React.FC = () => {
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const [webSocketUrl, setWebSocketUrl] = useState("");
|
||||
const handleFocus = () => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.select();
|
||||
}
|
||||
};
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const {
|
||||
token: { colorBgContainer, borderRadiusLG },
|
||||
} = theme.useToken();
|
||||
const [messages, setMessages] = useState<ChatMessage>([
|
||||
{
|
||||
message: "Hello, I'm the assistant! Ask me anything!",
|
||||
sender: "assistant",
|
||||
think: "",
|
||||
site: [],
|
||||
},
|
||||
]);
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
|
||||
async function get_ws() {
|
||||
const response = await fetch(BACKEND_WS_URL, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error("ReadableStream not found in response.");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log(data);
|
||||
setWebSocketUrl(data.url);
|
||||
}
|
||||
|
||||
const handleSend = async (message: string) => {
|
||||
await get_ws();
|
||||
setCollapsed(true);
|
||||
if (message.trim() === "") {
|
||||
return;
|
||||
}
|
||||
const newMessage = {
|
||||
message,
|
||||
sender: "user",
|
||||
think: "",
|
||||
site: [],
|
||||
};
|
||||
|
||||
const newMessages = [...messages, newMessage];
|
||||
|
||||
setMessages(newMessages);
|
||||
|
||||
setIsTyping(true);
|
||||
await processMessageToChatGPT(newMessages);
|
||||
};
|
||||
|
||||
async function processMessageToChatGPT(chatMessages: ChatMessage) {
|
||||
let apiMessages = chatMessages
|
||||
.map((messageObject) => {
|
||||
if (messageObject.message.trim() === "") {
|
||||
return null;
|
||||
}
|
||||
let role = messageObject.sender === "assistant" ? "assistant" : "user";
|
||||
return { role, content: messageObject.message };
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const apiRequestBody = {
|
||||
model: DEFAULT_MODEL,
|
||||
messages: [systemMessage, ...apiMessages],
|
||||
stream: true,
|
||||
};
|
||||
|
||||
const response = await fetch(BACKEND_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(apiRequestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error("ReadableStream not found in response.");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let accumulatedMessage = "";
|
||||
setMessages([
|
||||
...chatMessages,
|
||||
{
|
||||
message: "",
|
||||
sender: "assistant",
|
||||
think: "",
|
||||
site: [],
|
||||
},
|
||||
]);
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value);
|
||||
accumulatedMessage += chunk;
|
||||
|
||||
const lines = accumulatedMessage.split("\n");
|
||||
accumulatedMessage = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim() === "") continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(line.split("data: ")[1]);
|
||||
const content = parsed.choices[0]?.delta?.content || "";
|
||||
if (content) {
|
||||
setMessages((prevMessages) => [
|
||||
...prevMessages.slice(0, -1),
|
||||
{
|
||||
...prevMessages[prevMessages.length - 1],
|
||||
message:
|
||||
prevMessages[prevMessages.length - 1].message + content,
|
||||
sender: "assistant",
|
||||
site: [],
|
||||
},
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing JSON:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setIsTyping(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const scrollInterval = setInterval(() => {
|
||||
if (listRef.current) {
|
||||
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(scrollInterval);
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
style={{ minHeight: "100vh", display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
<Content style={{ padding: "0 48px", flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
background: colorBgContainer,
|
||||
minHeight: 600,
|
||||
padding: 24,
|
||||
borderRadius: borderRadiusLG,
|
||||
}}
|
||||
>
|
||||
<Flex vertical={true} gap={"large"}>
|
||||
<Flex gap={"large"} style={{ marginBottom: 30 }}>
|
||||
<Image
|
||||
width={48}
|
||||
src="logo512.png"
|
||||
onClick={() => {
|
||||
window.location.reload();
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
/>
|
||||
<Search
|
||||
ref={inputRef}
|
||||
placeholder=""
|
||||
allowClear
|
||||
enterButton="Search"
|
||||
size="large"
|
||||
onSearch={handleSend}
|
||||
onFocus={handleFocus}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex gap={"large"}>
|
||||
<Flex vertical={true} style={{ width: 500 }} gap={"large"}>
|
||||
{collapsed && (
|
||||
<List
|
||||
size="large"
|
||||
bordered
|
||||
dataSource={messages.slice(1)}
|
||||
style={{ color: "black" }}
|
||||
renderItem={(item) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
<Avatar
|
||||
src={
|
||||
item.sender === "user"
|
||||
? "user_avatar.svg"
|
||||
: "logo512.png"
|
||||
}
|
||||
/>
|
||||
}
|
||||
title={item.sender}
|
||||
description={item["message"]}
|
||||
/>
|
||||
{isTyping && item === messages[messages.length - 1] && (
|
||||
<Spin />
|
||||
)}
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<Browser webSocketUrl={webSocketUrl} activeKey={"3"} />
|
||||
</Flex>
|
||||
</Flex>
|
||||
</div>
|
||||
</Content>
|
||||
<Footer style={{ textAlign: "center" }}></Footer>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,384 @@
|
||||
/* CSS Variables for themes */
|
||||
html[data-theme="dark"] {
|
||||
--bg-primary: #272725;
|
||||
--bg-secondary: #171717;
|
||||
--border-color: #383838;
|
||||
--text-color: #ffffff;
|
||||
--tab-active-bg: #272725;
|
||||
--tab-hover-bg: #333333;
|
||||
--icon-color: #8a8a8a;
|
||||
--icon-hover-color: #ffffff;
|
||||
--error-color: #e53935;
|
||||
--offline-indicator-color: #e53935;
|
||||
--loading-overlay-bg: rgba(30, 30, 30, 0.8);
|
||||
--loading-spinner-color: #ffffff;
|
||||
}
|
||||
|
||||
html[data-theme="light"] {
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f5f5f5;
|
||||
--border-color: #e0e0e0;
|
||||
--text-color: #000000;
|
||||
--tab-active-bg: #e8e8e8;
|
||||
--tab-hover-bg: #efefef;
|
||||
--icon-color: #666666;
|
||||
--icon-hover-color: #000000;
|
||||
--error-color: #e53935;
|
||||
--offline-indicator-color: #e53935;
|
||||
--loading-overlay-bg: rgba(240, 240, 240, 0.8);
|
||||
--loading-spinner-color: #333333;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--bg-primary);
|
||||
border: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.browser-chrome {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
padding: 6px;
|
||||
gap: 4px;
|
||||
height: 36px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
align-items: center;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
min-width: 120px;
|
||||
max-width: 200px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
color: var(--text-color);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
position: relative;
|
||||
gap: 8px;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--tab-hover-bg);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: var(--tab-active-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.tab-favicon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
opacity: 0.6;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.address-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
height: 40px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.nav-buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-left: 8px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--icon-color);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: var(--icon-hover-color);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.url-bar {
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-color);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
|
||||
&:focus-within {
|
||||
outline: none;
|
||||
background: var(--tab-hover-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.url-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-color);
|
||||
font-family: 'Geist', sans-serif;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
background: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: none;
|
||||
|
||||
&.active {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&.loading::before {
|
||||
content: "Loading...";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: var(--text-color);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
font-size: 16px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
&.error::before {
|
||||
content: "Session released";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #fff;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
font-size: 16px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
&.tab-switching::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: var(--loading-overlay-bg);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
&.tab-switching::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
border: 4px solid transparent;
|
||||
border-top-color: var(--loading-spinner-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
z-index: 11;
|
||||
}
|
||||
}
|
||||
|
||||
.canvas {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
height: 36px;
|
||||
color: var(--text-color);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
font-size: 13px;
|
||||
box-sizing: border-box;
|
||||
min-width: 140px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.offline {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&.online {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.connecting {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.offline {
|
||||
background-color: var(--offline-indicator-color);
|
||||
}
|
||||
}
|
||||
|
||||
.url-security-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
fill: var(--icon-color);
|
||||
}
|
||||
|
||||
&.secure svg {
|
||||
fill: #4CAF50;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-favicon-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: none;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
border: 2px solid var(--icon-color);
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spinner-rotation 0.8s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.tab.loading {
|
||||
.tab-favicon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-favicon-spinner {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: translate(-50%, -50%) rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spinner-rotation {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,652 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from "react";
|
||||
import "./Browser.scss";
|
||||
|
||||
interface Tab {
|
||||
id: string;
|
||||
url: string;
|
||||
title: string;
|
||||
favicon: string | null;
|
||||
ws: WebSocket | null;
|
||||
receivedFirstFrame: boolean;
|
||||
lastImageData: string | null;
|
||||
isLoading: boolean;
|
||||
frameCount: number;
|
||||
canvasRef: React.RefObject<HTMLCanvasElement>;
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
currentImageWidth: number;
|
||||
currentImageHeight: number;
|
||||
reconnecting: boolean;
|
||||
intentionalClose: boolean;
|
||||
error: boolean;
|
||||
}
|
||||
|
||||
type ConnectionStatus = "online" | "offline" | "connecting";
|
||||
|
||||
const defaultWidth = 1920;
|
||||
const defaultHeight = 1080;
|
||||
|
||||
interface BrowserProps {
|
||||
webSocketUrl: string;
|
||||
activeKey?: string;
|
||||
}
|
||||
|
||||
const Browser: React.FC<BrowserProps> = ({ webSocketUrl, activeKey }) => {
|
||||
const [tabs, setTabs] = useState<Record<string, Tab>>({});
|
||||
const [activeTabId, setActiveTabId] = useState<string | null>(null);
|
||||
const [connectionStatus, setConnectionStatus] =
|
||||
useState<ConnectionStatus>("connecting");
|
||||
const [tabOrder, setTabOrder] = useState<string[]>([]);
|
||||
const [isUrlBarFocused, setIsUrlBarFocused] = useState(false);
|
||||
const urlTextRef = useRef<HTMLInputElement>(null);
|
||||
const wsDiscoveryRef = useRef<WebSocket | null>(null);
|
||||
const activeConnectionRetries = useRef<Record<string, number>>({});
|
||||
const singlePageMode = false;
|
||||
const interactive = true;
|
||||
|
||||
useEffect(() => {
|
||||
if (singlePageMode) return;
|
||||
const ws = new WebSocket(webSocketUrl + "?tabInfo=true");
|
||||
wsDiscoveryRef.current = ws;
|
||||
ws.onopen = () => setConnectionStatus("online");
|
||||
ws.onclose = () => setConnectionStatus("offline");
|
||||
ws.onerror = () => setConnectionStatus("offline");
|
||||
ws.onmessage = (event) => {
|
||||
const payload = JSON.parse(event.data);
|
||||
if (payload.type === "tabList" && payload.tabs) {
|
||||
handleTabList(payload.tabs, payload.firstTabId);
|
||||
} else if (payload.type === "tabClosed" && payload.pageId) {
|
||||
handleTabClosed(payload.pageId);
|
||||
} else if (payload.type === "activeTabChange" && payload.pageId) {
|
||||
setActiveTabId(payload.pageId);
|
||||
}
|
||||
};
|
||||
return () => ws.close();
|
||||
}, [webSocketUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTabId) return;
|
||||
const tab = tabs[activeTabId];
|
||||
if (!tab) return;
|
||||
if (tab.ws) return;
|
||||
connectTabWebSocket(activeTabId);
|
||||
}, [activeTabId, tabs]);
|
||||
|
||||
const handleTabList = useCallback((tabList: any[], firstTabId?: string) => {
|
||||
const newTabs: Record<string, Tab> = {};
|
||||
const order: string[] = [];
|
||||
tabList.forEach((tab) => {
|
||||
newTabs[tab.id] = {
|
||||
id: tab.id,
|
||||
url: tab.url,
|
||||
title: tab.title,
|
||||
favicon: tab.favicon,
|
||||
ws: null,
|
||||
receivedFirstFrame: false,
|
||||
lastImageData: null,
|
||||
isLoading: false,
|
||||
frameCount: 0,
|
||||
canvasRef:
|
||||
React.createRef<HTMLCanvasElement>() as React.RefObject<HTMLCanvasElement>,
|
||||
containerRef:
|
||||
React.createRef<HTMLDivElement>() as React.RefObject<HTMLDivElement>,
|
||||
currentImageWidth: defaultWidth,
|
||||
currentImageHeight: defaultHeight,
|
||||
reconnecting: false,
|
||||
intentionalClose: false,
|
||||
error: false,
|
||||
};
|
||||
order.push(tab.id);
|
||||
});
|
||||
setTabs(newTabs);
|
||||
setTabOrder(order);
|
||||
if (firstTabId && newTabs[firstTabId]) {
|
||||
setActiveTabId(firstTabId);
|
||||
} else if (tabList.length > 0) {
|
||||
setActiveTabId(tabList[0].id);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTabClosed = useCallback(
|
||||
(pageId: string) => {
|
||||
setTabs((prev) => {
|
||||
const updated = { ...prev };
|
||||
if (updated[pageId]?.ws) updated[pageId].ws?.close();
|
||||
delete updated[pageId];
|
||||
return updated;
|
||||
});
|
||||
setTabOrder((prev) => prev.filter((id) => id !== pageId));
|
||||
if (activeTabId === pageId) {
|
||||
const tabIds = tabOrder.filter((id) => id !== pageId);
|
||||
if (tabIds.length > 0) setActiveTabId(tabIds[0]);
|
||||
else setActiveTabId(null);
|
||||
}
|
||||
},
|
||||
[activeTabId, tabOrder],
|
||||
);
|
||||
|
||||
const updateTabInfo = useCallback(
|
||||
(pageId: string, url: string, title: string, favicon: string | null) => {
|
||||
setTabs((prev) => ({
|
||||
...prev,
|
||||
[pageId]: {
|
||||
...prev[pageId],
|
||||
url,
|
||||
title,
|
||||
favicon,
|
||||
},
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const connectTabWebSocket = (pageId: string) => {
|
||||
setTabs((prev) => {
|
||||
if (!prev[pageId]) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[pageId]: {
|
||||
...prev[pageId],
|
||||
isLoading: true,
|
||||
error: false,
|
||||
reconnecting: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
const ws = new WebSocket(
|
||||
webSocketUrl + `?pageId=${encodeURIComponent(pageId)}`,
|
||||
);
|
||||
ws.onopen = () => {
|
||||
setTabs((prev) => {
|
||||
if (!prev[pageId]) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[pageId]: {
|
||||
...prev[pageId],
|
||||
ws,
|
||||
isLoading: false,
|
||||
error: false,
|
||||
reconnecting: false,
|
||||
frameCount: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
setConnectionStatus("online");
|
||||
};
|
||||
ws.onclose = () => {
|
||||
setTabs((prev) => {
|
||||
if (!prev[pageId]) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[pageId]: {
|
||||
...prev[pageId],
|
||||
isLoading: false,
|
||||
error: true,
|
||||
reconnecting: false,
|
||||
ws: null,
|
||||
},
|
||||
};
|
||||
});
|
||||
setConnectionStatus("offline");
|
||||
};
|
||||
ws.onerror = () => {
|
||||
setTabs((prev) => {
|
||||
if (!prev[pageId]) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[pageId]: {
|
||||
...prev[pageId],
|
||||
isLoading: false,
|
||||
error: true,
|
||||
reconnecting: false,
|
||||
},
|
||||
};
|
||||
});
|
||||
setConnectionStatus("offline");
|
||||
};
|
||||
ws.onmessage = (event) => {
|
||||
const payload = JSON.parse(event.data);
|
||||
if (payload.type === "tabUpdate") {
|
||||
updateTabInfo(
|
||||
pageId,
|
||||
payload.url || "",
|
||||
payload.title || "",
|
||||
payload.favicon || null,
|
||||
);
|
||||
} else if (payload.type === "targetClosed") {
|
||||
handleTabClosed(pageId);
|
||||
}
|
||||
if (payload.data) {
|
||||
renderCanvasImage(
|
||||
pageId,
|
||||
payload.data,
|
||||
payload.url,
|
||||
payload.title,
|
||||
payload.favicon,
|
||||
);
|
||||
}
|
||||
};
|
||||
setTabs((prev) => {
|
||||
if (!prev[pageId]) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[pageId]: {
|
||||
...prev[pageId],
|
||||
ws,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const renderCanvasImage = (
|
||||
pageId: string,
|
||||
imageData: string,
|
||||
url?: string,
|
||||
title?: string,
|
||||
favicon?: string,
|
||||
) => {
|
||||
setTabs((prev) => {
|
||||
const updated = { ...prev };
|
||||
if (!updated[pageId]) return updated;
|
||||
updated[pageId].receivedFirstFrame = true;
|
||||
updated[pageId].lastImageData = imageData.startsWith(
|
||||
"data:image/jpeg;base64,",
|
||||
)
|
||||
? imageData
|
||||
: `data:image/jpeg;base64,${imageData}`;
|
||||
updated[pageId].isLoading = false;
|
||||
updated[pageId].error = false;
|
||||
if (url && !isUrlBarFocused) updated[pageId].url = url;
|
||||
if (title) updated[pageId].title = title;
|
||||
if (favicon) updated[pageId].favicon = favicon;
|
||||
updated[pageId].frameCount++;
|
||||
return updated;
|
||||
});
|
||||
setTimeout(() => {
|
||||
const tab = tabs[pageId];
|
||||
const canvas = tab?.canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d", { alpha: false });
|
||||
if (!ctx) return;
|
||||
const img = new window.Image();
|
||||
img.src = imageData.startsWith("data:image/jpeg;base64,")
|
||||
? imageData
|
||||
: `data:image/jpeg;base64,${imageData}`;
|
||||
img.onload = () => {
|
||||
setTabs((prev) => {
|
||||
const updated = { ...prev };
|
||||
if (!updated[pageId]) return updated;
|
||||
updated[pageId].currentImageWidth = img.naturalWidth;
|
||||
updated[pageId].currentImageHeight = img.naturalHeight;
|
||||
return updated;
|
||||
});
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const container = tab?.containerRef.current;
|
||||
const targetHeight = container?.clientHeight || defaultHeight;
|
||||
const targetWidth =
|
||||
targetHeight * (img.naturalWidth / img.naturalHeight);
|
||||
canvas.width = targetWidth * dpr;
|
||||
canvas.height = targetHeight * dpr;
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
ctx.scale(dpr, dpr);
|
||||
canvas.style.height = "100%";
|
||||
canvas.style.width = "auto";
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(
|
||||
img,
|
||||
0,
|
||||
0,
|
||||
Math.floor(canvas.width / dpr),
|
||||
Math.floor(canvas.height / dpr),
|
||||
);
|
||||
};
|
||||
}, 0);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTabId || activeKey !== "3") return;
|
||||
const tab = tabs[activeTabId];
|
||||
if (!tab) return;
|
||||
const canvas = tab.canvasRef.current;
|
||||
if (!canvas) return;
|
||||
// 鼠标事件
|
||||
const getScaledCoordinates = (e: MouseEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = tab.currentImageWidth / rect.width;
|
||||
const scaleY = tab.currentImageHeight / rect.height;
|
||||
return {
|
||||
x: Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
Math.round((e.clientX - rect.left) * scaleX),
|
||||
tab.currentImageWidth,
|
||||
),
|
||||
),
|
||||
y: Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
Math.round((e.clientY - rect.top) * scaleY),
|
||||
tab.currentImageHeight,
|
||||
),
|
||||
),
|
||||
};
|
||||
};
|
||||
const handleMouse = (e: MouseEvent, type: string) => {
|
||||
if (!tab.ws || tab.ws.readyState !== WebSocket.OPEN) return;
|
||||
const coords = getScaledCoordinates(e);
|
||||
const modifiers =
|
||||
(e.ctrlKey ? 2 : 0) |
|
||||
(e.shiftKey ? 8 : 0) |
|
||||
(e.altKey ? 1 : 0) |
|
||||
(e.metaKey ? 4 : 0);
|
||||
let button = "none";
|
||||
if (type === "mousePressed" || type === "mouseReleased") {
|
||||
button = e.button === 0 ? "left" : e.button === 1 ? "middle" : "right";
|
||||
}
|
||||
|
||||
const eventData = JSON.stringify({
|
||||
type: "mouseEvent",
|
||||
pageId: activeTabId,
|
||||
event: {
|
||||
type,
|
||||
x: coords.x,
|
||||
y: coords.y,
|
||||
button,
|
||||
modifiers,
|
||||
clickCount: (e as any).detail || 1,
|
||||
},
|
||||
});
|
||||
|
||||
tab.ws.send(eventData);
|
||||
};
|
||||
let moveTimeout: any = null;
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (moveTimeout) clearTimeout(moveTimeout);
|
||||
moveTimeout = setTimeout(() => handleMouse(e, "mouseMoved"), 20);
|
||||
};
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (!tab.ws || tab.ws.readyState !== WebSocket.OPEN) return;
|
||||
const coords = getScaledCoordinates(e as any);
|
||||
const modifiers =
|
||||
(e.ctrlKey ? 2 : 0) |
|
||||
(e.shiftKey ? 8 : 0) |
|
||||
(e.altKey ? 1 : 0) |
|
||||
(e.metaKey ? 4 : 0);
|
||||
|
||||
const eventData = JSON.stringify({
|
||||
type: "mouseEvent",
|
||||
pageId: activeTabId,
|
||||
event: {
|
||||
type: "mouseWheel",
|
||||
x: coords.x,
|
||||
y: coords.y,
|
||||
button: "none",
|
||||
modifiers,
|
||||
deltaX: e.deltaX,
|
||||
deltaY: e.deltaY,
|
||||
},
|
||||
});
|
||||
|
||||
tab.ws.send(eventData);
|
||||
e.preventDefault();
|
||||
};
|
||||
canvas.addEventListener("mousedown", (e) => handleMouse(e, "mousePressed"));
|
||||
canvas.addEventListener("mouseup", (e) => handleMouse(e, "mouseReleased"));
|
||||
canvas.addEventListener("mousemove", handleMouseMove);
|
||||
canvas.addEventListener("wheel", handleWheel, { passive: false });
|
||||
|
||||
const handleKey = (e: KeyboardEvent, type: "keyDown" | "keyUp") => {
|
||||
if (document.activeElement === urlTextRef.current) return;
|
||||
if (!tab.ws || tab.ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
const eventData = JSON.stringify({
|
||||
type: "keyEvent",
|
||||
pageId: activeTabId,
|
||||
event: {
|
||||
type,
|
||||
text: e.key.length === 1 ? e.key : undefined,
|
||||
code: e.code,
|
||||
key: e.key,
|
||||
keyCode: e.keyCode,
|
||||
},
|
||||
});
|
||||
};
|
||||
const keydown = (e: KeyboardEvent) => handleKey(e, "keyDown");
|
||||
const keyup = (e: KeyboardEvent) => handleKey(e, "keyUp");
|
||||
document.addEventListener("keydown", keydown);
|
||||
document.addEventListener("keyup", keyup);
|
||||
return () => {
|
||||
canvas.removeEventListener("mousedown", (e) =>
|
||||
handleMouse(e, "mousePressed"),
|
||||
);
|
||||
canvas.removeEventListener("mouseup", (e) =>
|
||||
handleMouse(e, "mouseReleased"),
|
||||
);
|
||||
canvas.removeEventListener("mousemove", handleMouseMove);
|
||||
canvas.removeEventListener("wheel", handleWheel);
|
||||
document.removeEventListener("keydown", keydown);
|
||||
document.removeEventListener("keyup", keyup);
|
||||
};
|
||||
}, [activeTabId, tabs]);
|
||||
|
||||
const handleUrlSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!urlTextRef.current || !activeTabId) return;
|
||||
const url = urlTextRef.current.value;
|
||||
handleNavigation("url", url);
|
||||
urlTextRef.current.blur();
|
||||
};
|
||||
|
||||
const handleNavigation = (
|
||||
action: "back" | "forward" | "refresh" | "url",
|
||||
url?: string,
|
||||
) => {
|
||||
if (!activeTabId || !tabs[activeTabId]?.ws) return;
|
||||
const ws = tabs[activeTabId].ws;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
//if (ws.readyState !== WebSocket.OPEN) return;
|
||||
setTabs((prev) => ({
|
||||
...prev,
|
||||
[activeTabId]: {
|
||||
...prev[activeTabId],
|
||||
isLoading: true,
|
||||
frameCount: 0,
|
||||
},
|
||||
}));
|
||||
const eventData = JSON.stringify({
|
||||
type: "navigation",
|
||||
pageId: activeTabId,
|
||||
event: action === "url" ? { url } : { action },
|
||||
});
|
||||
|
||||
console.warn("Navigation Event:", {
|
||||
eventString: eventData,
|
||||
currentUrl: tabs[activeTabId].url,
|
||||
pageTitle: tabs[activeTabId].title,
|
||||
currentBase64Data: tabs[activeTabId].lastImageData,
|
||||
action,
|
||||
targetUrl: url,
|
||||
});
|
||||
|
||||
ws.send(eventData);
|
||||
if (action === "url" && url) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "navigation",
|
||||
url,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const isSecure = (url: string) =>
|
||||
url &&
|
||||
(url.toLowerCase().startsWith("https://") ||
|
||||
url.toLowerCase().startsWith("https:"));
|
||||
|
||||
// UI
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="browser-chrome">
|
||||
<div className="tab-bar" id="tab-bar">
|
||||
<div
|
||||
className={`connection-status ${connectionStatus}`}
|
||||
id="connection-status"
|
||||
>
|
||||
<div className={`status-indicator ${connectionStatus}`}></div>
|
||||
<span>
|
||||
{connectionStatus === "online"
|
||||
? "Session Online"
|
||||
: connectionStatus === "offline"
|
||||
? "Session Offline"
|
||||
: "Session Connecting..."}
|
||||
</span>
|
||||
</div>
|
||||
{tabOrder.map((id) => {
|
||||
const tab = tabs[id];
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className={`tab${activeTabId === id ? " active" : ""}${
|
||||
tab.isLoading ? " loading" : ""
|
||||
}`}
|
||||
onClick={() => setActiveTabId(id)}
|
||||
>
|
||||
<img
|
||||
className="tab-favicon"
|
||||
src={tab.favicon || ""}
|
||||
style={{ display: tab.favicon ? "block" : "none" }}
|
||||
alt=""
|
||||
/>
|
||||
<div className="tab-favicon-spinner"></div>
|
||||
<div className="tab-title">{tab.title || "New Tab"}</div>
|
||||
<div
|
||||
className="tab-close"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleTabClosed(id);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="address-bar">
|
||||
<div className="nav-buttons">
|
||||
<button
|
||||
className="nav-button"
|
||||
onClick={() => handleNavigation("back")}
|
||||
disabled={!activeTabId}
|
||||
>
|
||||
<svg className="icon" viewBox="0 0 24 24">
|
||||
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className="nav-button"
|
||||
onClick={() => handleNavigation("forward")}
|
||||
disabled={!activeTabId}
|
||||
>
|
||||
<svg className="icon" viewBox="0 0 24 24">
|
||||
<path d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8-8-8z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className="nav-button"
|
||||
onClick={() => handleNavigation("refresh")}
|
||||
disabled={!activeTabId}
|
||||
>
|
||||
<svg className="icon" viewBox="0 0 24 24">
|
||||
<path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<form className="url-bar" onSubmit={handleUrlSubmit}>
|
||||
<div
|
||||
className={`url-security-icon${
|
||||
isSecure(tabs[activeTabId || ""]?.url || "") ? " secure" : ""
|
||||
}`}
|
||||
id="url-security-icon"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
id="lock-icon"
|
||||
style={{
|
||||
display: isSecure(tabs[activeTabId || ""]?.url || "")
|
||||
? "block"
|
||||
: "none",
|
||||
}}
|
||||
>
|
||||
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z" />
|
||||
</svg>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
id="unlock-icon"
|
||||
style={{
|
||||
display: isSecure(tabs[activeTabId || ""]?.url || "")
|
||||
? "none"
|
||||
: "block",
|
||||
}}
|
||||
>
|
||||
<path d="M12 17c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm6-9h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6h1.9c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm0 12H6V10h12v10z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="url-text"
|
||||
className="url-input"
|
||||
ref={urlTextRef}
|
||||
value={tabs[activeTabId || ""]?.url || ""}
|
||||
onChange={(e) => {
|
||||
if (!activeTabId || activeKey !== "3") return;
|
||||
setTabs((prev) => ({
|
||||
...prev,
|
||||
[activeTabId]: {
|
||||
...prev[activeTabId],
|
||||
url: e.target.value,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
onFocus={() => setIsUrlBarFocused(true)}
|
||||
onBlur={() => setIsUrlBarFocused(false)}
|
||||
disabled={!activeTabId}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div className="content">
|
||||
{tabOrder.map((id) => {
|
||||
const tab = tabs[id];
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
ref={tab.containerRef}
|
||||
className={`canvas-container${
|
||||
activeTabId === id ? " active" : ""
|
||||
}${tab.isLoading ? " loading" : ""}${tab.error ? " error" : ""}`}
|
||||
style={{
|
||||
display: activeTabId === id ? "flex" : "none",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
ref={tab.canvasRef}
|
||||
className="canvas"
|
||||
width={defaultWidth}
|
||||
height={defaultHeight}
|
||||
style={{ height: "100%", width: "auto" }}
|
||||
tabIndex={0}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Browser;
|
||||
@@ -0,0 +1,13 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
|
||||
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
|
||||
monospace;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App";
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById("root") as HTMLElement,
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user