(
30 | selectedNode?.title ?? '',
31 | (val) => useTreeStore.getState().patchSelectedNode('title', val ?? '')
32 | );
33 | const [url, setUrl, onUrlBlur] = useEditValue(
34 | selectedNode?.url ?? '',
35 | (val) => useTreeStore.getState().patchSelectedNode('url', val ?? '')
36 | );
37 |
38 | if (!selectedNode) {
39 | return ;
40 | }
41 |
42 | return (
43 |
44 |
45 |
52 |
59 |
60 |
61 | {isValidUrl(selectedNode.url) ? (
62 |
63 | ) : (
64 |
65 | )}
66 |
67 |
68 | );
69 | });
70 | WebContent.displayName = 'WebContent';
71 |
--------------------------------------------------------------------------------
/src/renderer/components/main/WebInvalidUrl.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useEffect } from 'react';
3 | import styled from 'styled-components';
4 | import webpageSvg from '../../assets/web-page.svg';
5 | import { useTreeStore } from '../../store/tree';
6 |
7 | const WebInvalidUrlRoot = styled.div`
8 | height: 100%;
9 | width: 100%;
10 | display: flex;
11 | justify-content: center;
12 | align-items: center;
13 | font-size: 24px;
14 | flex-direction: column;
15 |
16 | img {
17 | width: 120px;
18 | }
19 | `;
20 |
21 | export const WebInvalidUrl: React.FC = React.memo(() => {
22 | useEffect(() => {
23 | const selectedNode = useTreeStore.getState().selectedNode;
24 | if (selectedNode) {
25 | window.electron.ipcRenderer.sendMessage('unmount-webview', {
26 | key: selectedNode.key,
27 | });
28 | }
29 | window.electron.ipcRenderer.sendMessage('hide-all-webview');
30 | }, []);
31 |
32 | return (
33 |
34 |
35 |

36 |
37 | Please input valid url to load Url
38 |
39 | );
40 | });
41 | WebInvalidUrl.displayName = 'WebInvalidUrl';
42 |
--------------------------------------------------------------------------------
/src/renderer/components/main/WebPlaceholder.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useEffect } from 'react';
3 | import styled from 'styled-components';
4 | import webpageSvg from '../../assets/web-page.svg';
5 | import { AddWebsiteBtn } from '../AddWebsiteBtn';
6 |
7 | const WebPlaceholderRoot = styled.div`
8 | height: 100%;
9 | width: 100%;
10 | display: flex;
11 | justify-content: center;
12 | align-items: center;
13 | font-size: 24px;
14 | flex-direction: column;
15 |
16 | img {
17 | width: 120px;
18 | }
19 | `;
20 |
21 | export const WebPlaceholder: React.FC = React.memo(() => {
22 | useEffect(() => {
23 | window.electron.ipcRenderer.sendMessage('hide-all-webview');
24 | }, []);
25 |
26 | return (
27 |
28 |
29 |

30 |
31 | Please Select Any Page on Left Website Tree
32 |
33 | OR
34 |
35 |
38 |
39 | );
40 | });
41 | WebPlaceholder.displayName = 'WebPlaceholder';
42 |
--------------------------------------------------------------------------------
/src/renderer/components/main/WebviewRender.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from 'react';
2 | import { WebsiteTreeNode } from '../../store/tree';
3 |
4 | export const WebviewRender: React.FC<{ node: WebsiteTreeNode }> = React.memo(
5 | (props) => {
6 | const containerRef = useRef(null);
7 | const node = props.node;
8 |
9 | useEffect(() => {
10 | if (!containerRef.current) {
11 | return;
12 | }
13 |
14 | const rect = containerRef.current.getBoundingClientRect();
15 |
16 | window.electron.ipcRenderer.sendMessage('mount-webview', {
17 | key: node.key,
18 | url: node.url,
19 | rect: {
20 | x: rect.x,
21 | y: rect.y,
22 | width: rect.width,
23 | height: rect.height,
24 | },
25 | });
26 | }, [node.key, node.url]);
27 |
28 | useEffect(() => {
29 | if (!containerRef.current) {
30 | return;
31 | }
32 |
33 | const resizeObserver = new ResizeObserver((entries) => {
34 | entries.forEach((entry) => {
35 | const { target } = entry;
36 | if (!target.parentElement) {
37 | return;
38 | }
39 |
40 | const rect = target.getBoundingClientRect();
41 |
42 | window.electron.ipcRenderer.sendMessage('update-webview-rect', {
43 | key: node.key,
44 | rect: {
45 | x: rect.x,
46 | y: rect.y,
47 | width: rect.width,
48 | height: rect.height,
49 | },
50 | });
51 | });
52 | });
53 |
54 | resizeObserver.observe(containerRef.current);
55 |
56 | return () => {
57 | if (containerRef.current) {
58 | resizeObserver.unobserve(containerRef.current);
59 | }
60 | };
61 | }, [node.key]);
62 |
63 | return ;
64 | }
65 | );
66 | WebviewRender.displayName = 'WebviewRender';
67 |
--------------------------------------------------------------------------------
/src/renderer/index.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 | Webbox
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/src/renderer/index.tsx:
--------------------------------------------------------------------------------
1 | import { createRoot } from 'react-dom/client';
2 | import App from './App';
3 | import 'allotment/dist/style.css';
4 | import '@arco-design/web-react/dist/css/arco.css';
5 |
6 | const container = document.getElementById('root')!;
7 | const root = createRoot(container);
8 | root.render();
9 |
--------------------------------------------------------------------------------
/src/renderer/preload.d.ts:
--------------------------------------------------------------------------------
1 | import { ElectronHandler } from '../main/preload';
2 |
3 | declare global {
4 | // eslint-disable-next-line no-unused-vars
5 | interface Window {
6 | electron: ElectronHandler;
7 | }
8 | }
9 |
10 | export {};
11 |
--------------------------------------------------------------------------------
/src/renderer/store/tree.ts:
--------------------------------------------------------------------------------
1 | import {
2 | NodeInstance,
3 | TreeDataType,
4 | } from '@arco-design/web-react/es/Tree/interface';
5 | import { create } from 'zustand';
6 | import { persist } from 'zustand/middleware';
7 | import { immer } from 'zustand/middleware/immer';
8 | import { nanoid } from 'nanoid';
9 |
10 | export type WebsiteTreeNode = {
11 | title: string;
12 | key: string;
13 | children?: WebsiteTreeNode[];
14 | url: string;
15 | isLeaf?: boolean;
16 | };
17 |
18 | interface TreeStoreState {
19 | selectedNode: WebsiteTreeNode | null;
20 | treeData: WebsiteTreeNode[];
21 | expandedKeys: string[];
22 | setSelectedNode: (selectedNode: WebsiteTreeNode | null) => void;
23 | setTreeData: (treeData: WebsiteTreeNode[]) => void;
24 | setExpandedKeys: (expandedKeys: string[]) => void;
25 | addTreeNode: (treeNode: WebsiteTreeNode) => void;
26 | addTreeNodeChildren: (parentKey: string, treeNode: WebsiteTreeNode) => void;
27 | moveTreeNode: (
28 | dragNode: NodeInstance | null,
29 | dropNode: NodeInstance | null,
30 | dropPosition: number
31 | ) => void;
32 | deleteTreeNode: (key: string) => void;
33 | patchSelectedNode: >(
34 | key: T,
35 | value: WebsiteTreeNode[T]
36 | ) => void;
37 | }
38 |
39 | const defaultTreeData = [
40 | {
41 | key: 'baidu',
42 | title: 'Baidu',
43 | url: 'https://baidu.com',
44 | children: [],
45 | isLeaf: false,
46 | },
47 | {
48 | key: 'bing',
49 | title: 'Bing',
50 | url: 'https://cn.bing.com/',
51 | children: [],
52 | isLeaf: false,
53 | },
54 | {
55 | key: 'webbox',
56 | title: 'webbox',
57 | url: 'https://github.com/msgbyte/webbox',
58 | children: [],
59 | isLeaf: false,
60 | },
61 | ];
62 |
63 | export const useTreeStore = create()(
64 | persist(
65 | immer((set) => ({
66 | treeData: defaultTreeData,
67 | selectedNode: null,
68 | expandedKeys: [],
69 | setSelectedNode: (selectedNode: WebsiteTreeNode | null) => {
70 | set({
71 | selectedNode,
72 | });
73 | },
74 | setTreeData: (treeData: WebsiteTreeNode[]) => {
75 | set({
76 | treeData,
77 | });
78 | },
79 | setExpandedKeys: (expandedKeys: string[]) => {
80 | set({
81 | expandedKeys,
82 | });
83 | },
84 | addTreeNode: (treeNode: WebsiteTreeNode) => {
85 | set((state) => {
86 | state.treeData.push(treeNode);
87 | });
88 | },
89 | addTreeNodeChildren: (parentKey: string, treeNode: WebsiteTreeNode) => {
90 | set((state) => {
91 | const treeData = state.treeData;
92 |
93 | const targetTreeNode = findTreeNode(treeData, parentKey);
94 | if (!targetTreeNode) {
95 | return;
96 | }
97 |
98 | if (!targetTreeNode.children) {
99 | targetTreeNode.children = [];
100 | }
101 | targetTreeNode.children.push(treeNode);
102 | });
103 | },
104 | moveTreeNode: (
105 | dragNode: NodeInstance | null,
106 | dropNode: NodeInstance | null,
107 | dropPosition: number
108 | ) => {
109 | set((state) => {
110 | if (!dragNode) {
111 | return;
112 | }
113 |
114 | if (!dropNode) {
115 | return;
116 | }
117 |
118 | const loop = (
119 | data: TreeDataType[],
120 | key: string,
121 | callback: (
122 | item: TreeDataType,
123 | index: number,
124 | arr: TreeDataType[]
125 | ) => void
126 | ) => {
127 | data.some((item, index, arr) => {
128 | if (item.key === key) {
129 | callback(item, index, arr);
130 | return true;
131 | }
132 |
133 | if (item.children) {
134 | return loop(item.children, key, callback);
135 | }
136 | });
137 | };
138 |
139 | const data = [...state.treeData];
140 | let dragItem: TreeDataType;
141 | loop(data, dragNode.props._key ?? '', (item, index, arr) => {
142 | arr.splice(index, 1);
143 | dragItem = item;
144 | });
145 |
146 | if (dropPosition === 0) {
147 | loop(data, dropNode.props._key ?? '', (item, index, arr) => {
148 | item.children = item.children || [];
149 | item.children.push(dragItem);
150 | });
151 | } else {
152 | loop(data, dropNode.props._key ?? '', (item, index, arr) => {
153 | arr.splice(dropPosition < 0 ? index : index + 1, 0, dragItem);
154 | });
155 | }
156 |
157 | state.treeData = [...data];
158 | });
159 | },
160 | deleteTreeNode: (key: string) => {
161 | set((state) => {
162 | if (state.selectedNode && state.selectedNode.key === key) {
163 | state.selectedNode = null;
164 | }
165 |
166 | deleteTreeNode(state.treeData, key);
167 | });
168 | },
169 | patchSelectedNode: (key, value) => {
170 | set((state) => {
171 | if (!state.selectedNode) {
172 | return;
173 | }
174 |
175 | // Check is changed
176 | if (state.selectedNode[key] === value) {
177 | return;
178 | }
179 |
180 | const node = findTreeNode(state.treeData, state.selectedNode.key);
181 | if (!node) {
182 | return;
183 | }
184 |
185 | state.selectedNode[key] = value;
186 | node[key] = value;
187 | });
188 | },
189 | })),
190 | {
191 | name: 'webbox-tree',
192 | partialize: (state) => ({
193 | treeData: state.treeData,
194 | expandedKeys: state.expandedKeys,
195 | }),
196 | }
197 | )
198 | );
199 |
200 | function findTreeNode(
201 | treeData: WebsiteTreeNode[],
202 | key: string
203 | ): WebsiteTreeNode | null {
204 | for (const node of treeData) {
205 | if (node.key === key) {
206 | return node;
207 | }
208 |
209 | if (node.children && node.children.length > 0) {
210 | const res = findTreeNode(node.children, key);
211 | if (res) {
212 | return res;
213 | }
214 | }
215 | }
216 |
217 | return null;
218 | }
219 |
220 | /**
221 | * in-place algorithm
222 | */
223 | function deleteTreeNode(treeData: WebsiteTreeNode[], key: string): boolean {
224 | for (let i = 0; i < treeData.length; i++) {
225 | const node = treeData[i];
226 | if (node.key === key) {
227 | treeData.splice(i, 1);
228 | return true;
229 | }
230 |
231 | if (node.children && node.children.length > 0) {
232 | const fixed = deleteTreeNode(node.children, key);
233 | if (fixed) {
234 | return true;
235 | }
236 | }
237 | }
238 |
239 | return false;
240 | }
241 |
242 | export function generateDefaultNode(): WebsiteTreeNode {
243 | const key = nanoid();
244 | return {
245 | key,
246 | title: 'Untitled',
247 | url: '',
248 | children: [],
249 | isLeaf: false,
250 | };
251 | }
252 |
--------------------------------------------------------------------------------
/src/renderer/utils/dom.ts:
--------------------------------------------------------------------------------
1 | // react stopPropagation
2 | export function stopPropagation(e: React.BaseSyntheticEvent) {
3 | if (e && e.stopPropagation) {
4 | e.stopPropagation();
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/src/renderer/utils/hooks/useEditValue.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useLayoutEffect, useState } from 'react';
2 |
3 | export function useEditValue(value: T, onChange: (val: T) => void) {
4 | const [inner, setInner] = useState(value);
5 |
6 | useLayoutEffect(() => {
7 | setInner(value);
8 | }, [value]);
9 |
10 | const onSave = useCallback(() => {
11 | onChange(inner);
12 | }, [inner, onChange]);
13 |
14 | return [inner, setInner, onSave] as const;
15 | }
16 |
--------------------------------------------------------------------------------
/src/renderer/utils/index.ts:
--------------------------------------------------------------------------------
1 | import urlRegex from 'url-regex';
2 |
3 | /**
4 | * Check input is a valid url
5 | */
6 | export function isValidUrl(url: string): boolean {
7 | return urlRegex({ exact: true }).test(url);
8 | }
9 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "incremental": true,
4 | "target": "es2021",
5 | "module": "commonjs",
6 | "lib": ["dom", "es2021"],
7 | "jsx": "react-jsx",
8 | "strict": true,
9 | "sourceMap": true,
10 | "moduleResolution": "node",
11 | "esModuleInterop": true,
12 | "allowSyntheticDefaultImports": true,
13 | "skipLibCheck": true,
14 | "resolveJsonModule": true,
15 | "allowJs": true,
16 | "outDir": ".erb/dll"
17 | },
18 | "exclude": ["test", "release/build", "release/app/dist", ".erb/dll"]
19 | }
20 |
--------------------------------------------------------------------------------