reimplement the editor use lastest antd and other libs, feature of this commit:

1. file manger
2. highlight for click input or output
3. selector of subtress
4. autocomplete for input or output
This commit is contained in:
codetypes committed 2024-04-17 10:35:51 +08:00
1 parent 91da6f0071
commit 2e5c08b2ad
84 files changed
+14250 -11038

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
**/*.js
**/*.d.ts
electron/
common/
.eslintrc.cjs
playwright.config.ts
vite.config.ts
+40
View File
@@ -0,0 +1,40 @@
module.exports = {
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
// TODO
// "plugin:@typescript-eslint/recommended-type-checked",
],
plugins: ["@typescript-eslint", "@stylistic/js", "react-refresh"],
parser: "@typescript-eslint/parser",
rules: {
"@typescript-eslint/no-namespace": "off",
"@typescript-eslint/no-shadow": "error",
"react-refresh/only-export-components": "warn",
// TODO:
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/no-floating-promises": "off",
// https://eslint.style/rules/js/lines-between-class-members
"@stylistic/js/lines-between-class-members": [
"error",
{
enforce: [
{ blankLine: "always", prev: "method", next: "method" },
{ blankLine: "always", prev: "field", next: "method" },
{ blankLine: "always", prev: "method", next: "field" },
],
},
],
},
parserOptions: {
project: true,
tsconfigRootDir: __dirname,
},
root: true,
};
+32 -4
View File
@@ -1,4 +1,32 @@
node_modules/
dist/
build/
settings.json
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
dist-electron
release
*.local
# Editor directories and files
.vscode/.debug.env
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
#lockfile
pnpm-lock.yaml
yarn.lock
/test-results/
/playwright-report/
/playwright/.cache/
+1 -3
View File
@@ -1,3 +1 @@
# registry=https://registry.npm.taobao.org/
# ELECTRON_MIRROR=https://registry.npmmirror.com/-/binary/electron/
# ELECTRON_BUILDER_BINARIES_MIRROR=https://registry.npmmirror.com/-/binary/electron-builder-binaries/
shamefully-hoist=true
+1 -1
View File
@@ -1,7 +1,7 @@
{
"endOfLine": "lf",
"semi": true,
"tabWidth": 4,
"tabWidth": 2,
"printWidth": 100,
"trailingComma": "es5"
}
+23
View File
@@ -0,0 +1,23 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { createRequire } from 'node:module'
import { spawn } from 'node:child_process'
const pkg = createRequire(import.meta.url)('../package.json')
const __dirname = path.dirname(fileURLToPath(import.meta.url))
// write .debug.env
const envContent = Object.entries(pkg.debug.env).map(([key, val]) => `${key}=${val}`)
fs.writeFileSync(path.join(__dirname, '.debug.env'), envContent.join('\n'))
// bootstrap
spawn(
// TODO: terminate `npm run dev` when Debug exits.
process.platform === 'win32' ? 'npm.cmd' : 'npm',
['run', 'dev'],
{
stdio: 'inherit',
env: Object.assign(process.env, { VSCODE_DEBUG: 'true' }),
},
)
+7
View File
@@ -0,0 +1,7 @@
{
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
"mrmlnc.vscode-json5"
]
}
+47
View File
@@ -0,0 +1,47 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"compounds": [
{
"name": "Debug App",
"preLaunchTask": "Before Debug",
"configurations": ["Debug Main Process", "Debug Renderer Process"],
"presentation": {
"hidden": false,
"group": "",
"order": 1
},
"stopAll": true
}
],
"configurations": [
{
"name": "Debug Main Process",
"type": "node",
"request": "launch",
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron",
"windows": {
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron.cmd"
},
"runtimeArgs": ["--no-sandbox", "--remote-debugging-port=9229", "."],
"envFile": "${workspaceFolder}/.vscode/.debug.env",
"console": "integratedTerminal"
},
{
"name": "Debug Renderer Process",
"port": 9229,
"request": "attach",
"type": "chrome",
"timeout": 60000,
"skipFiles": [
"<node_internals>/**",
"${workspaceRoot}/node_modules/**",
"${workspaceRoot}/dist-electron/**",
// Skip files in host(VITE_DEV_SERVER_URL)
"http://127.0.0.1:7777/**"
]
}
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.tsc.autoDetect": "off",
"json.schemas": [
{
"fileMatch": [
"/*electron-builder.json5",
"/*electron-builder.json"
],
"url": "https://json.schemastore.org/electron-builder"
}
]
}
+31
View File
@@ -0,0 +1,31 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
"label": "Before Debug",
"type": "shell",
"command": "node .vscode/.debug.script.mjs",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"fileLocation": "relative",
"pattern": {
// TODO: correct "regexp"
"regexp": "^([a-zA-Z]\\:\/?([\\w\\-]\/?)+\\.\\w+):(\\d+):(\\d+): (ERROR|WARNING)\\: (.*)$",
"file": 1,
"line": 3,
"column": 4,
"code": 5,
"message": 6
},
"background": {
"activeOnStart": true,
"beginsPattern": "^.*VITE v.* ready in \\d* ms.*$",
"endsPattern": "^.*\\[startup\\] Electron App.*$"
}
}
}
]
}
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

-46
View File
@@ -1,46 +0,0 @@
import * as fs from 'fs';
import * as path from 'path';
import { BehaviorNodeModel, BehaviorTreeModel } from './BehaviorTreeModel';
export default (scriptPath: string, dirPath: string) => {
console.log("run script", scriptPath);
const str = fs.readFileSync(scriptPath, "utf8");
const script = eval(str);
const batchExec = (filePath: string) => {
const files = fs.readdirSync(filePath);
files.forEach((filename) => {
var filedir = path.join(filePath, filename);
const stats = fs.statSync(filedir);
if (stats.isFile() && filename.match(/.\.json$/)) {
const str = fs.readFileSync(filedir, "utf8");
let tree: BehaviorTreeModel = JSON.parse(str);
if (script.processTree) {
tree = script.processTree(tree);
}
if (tree && script.processNode) {
const processNode = (node: BehaviorNodeModel) => {
script.processNode(node, tree);
if (node.children) {
for (let child of node.children) {
processNode(child);
}
}
}
processNode(tree.root);
}
if (tree) {
fs.writeFileSync(
filedir,
JSON.stringify(tree)
);
}
}
if (stats.isDirectory()) {
batchExec(filedir);
}
});
}
batchExec(dirPath);
console.log("run script", scriptPath, "done!");
}
-56
View File
@@ -1,56 +0,0 @@
import { TreeGraphData } from "@antv/g6/lib/types";
export interface ArgsOptional {
name: string;
value: string | number;
}
export interface ArgsDefType {
name: string;
type: string;
desc: string;
default: string;
options: ArgsOptional[];
}
export interface BehaviorNodeTypeModel {
name: string;
type?: string;
desc?: string;
args?: ArgsDefType[];
input?: string[];
output?: string[];
doc?: string;
}
export interface BehaviorNodeModel {
id: number;
name: string;
desc?: string;
args?: { [key: string]: any };
input?: string[];
output?: string[];
children?: BehaviorNodeModel[];
debug?: boolean;
path?: string;
}
export interface BehaviorTreeModel {
name: string;
desc?: string;
root: BehaviorNodeModel;
}
export interface GraphNodeModel extends TreeGraphData {
name: string;
desc?: string;
args?: { [key: string]: any };
input?: string[];
output?: string[];
children?: GraphNodeModel[];
conf: BehaviorNodeTypeModel;
debug?: boolean;
parent?: string;
path?: string;
size?: number[];
}
-17
View File
@@ -1,17 +0,0 @@
export default {
OPEN_FILE: "OPEN_FILE",
OPEN_DIR: "OPEN_DIR",
RELOAD_SERVER: "RELOAD_SERVER",
BATCH_EXEC: "BATCH_EXEC",
CREATE_TREE: "CREATE_TREE",
CREATE_NODE: "CREATE_NODE",
DELETE_NODE: "DELETE_NODE",
COPY_NODE: "COPY_NODE",
PASTE_NODE: "PASTE_NODE",
SAVE_ALL: "SAVE_ALL",
BUILD: "BUILD",
SAVE: "SAVE",
SAVE_AS_SUBTREE: "SAVE_AS_SUBTREE",
UNDO: "UNDO",
REDO: "REDO",
};
-203
View File
@@ -1,203 +0,0 @@
import { message } from "antd";
import * as fs from "fs";
import * as path from "path";
import Settings from "../main-process/Settings";
import { BehaviorNodeModel, BehaviorTreeModel, GraphNodeModel } from "./BehaviorTreeModel";
interface INodeDataLike {
path?: string;
id: string | number;
}
export const isSubtree = (nodeData: INodeDataLike) => {
return nodeData.path && nodeData.id.toString() !== "1";
};
const parsingStack: string[] = [];
export const cloneNodeData = (nodeData: GraphNodeModel) => {
const newData: BehaviorNodeModel = {
id: Number(nodeData.id),
name: nodeData.name,
desc: nodeData.desc,
path: nodeData.path,
};
if (nodeData.input) {
newData.input = [];
for (let v of nodeData.input) {
newData.input.push(v || "");
}
}
if (nodeData.output) {
newData.output = [];
for (let v of nodeData.output) {
newData.output.push(v || "");
}
}
if (nodeData.args) {
newData.args = {};
for (let k in nodeData.args) {
let v = nodeData.args[k];
newData.args[k] = v;
}
}
if (nodeData.children && !isSubtree(nodeData)) {
newData.children = [];
for (let child of nodeData.children) {
newData.children.push(cloneNodeData(child));
}
}
return newData;
};
export const refreshNodeId = (nodeData: GraphNodeModel, id?: number) => {
if (!id) {
id = 1;
}
nodeData.id = (id++).toString();
if (nodeData.children) {
nodeData.children.forEach((child) => {
child.parent = nodeData.id;
id = refreshNodeId(child, id);
});
}
return id;
};
export const calcTreeNodeSize = (treeNode: GraphNodeModel) => {
var height = 40 + 2;
const updateHeight = (obj: any) => {
if (Array.isArray(obj) || (obj && Object.keys(obj).length > 0)) {
const { str, line } = toBreakWord(`参数:${JSON.stringify(obj)}`, 35);
height += 20 * line;
}
};
if (treeNode.path) {
height += 20;
}
updateHeight(treeNode.args);
updateHeight(treeNode.input);
updateHeight(treeNode.output);
return [200, height];
};
export const createTreeData = (bNode: BehaviorNodeModel, settings: Settings, parent?: string) => {
let treeData: GraphNodeModel = {
id: bNode.id.toString(),
name: bNode.name,
desc: bNode.desc,
args: bNode.args,
input: bNode.input,
output: bNode.output,
debug: bNode.debug,
conf: settings.getNodeConf(bNode.name),
parent: parent,
};
treeData.size = calcTreeNodeSize(treeData);
if (!parent) {
parsingStack.length = 0;
}
if (bNode.children) {
treeData.children = [];
bNode.children.forEach((child) => {
treeData.children.push(createTreeData(child, settings, treeData.id));
});
} else if (bNode.path) {
if (parsingStack.indexOf(bNode.path) >= 0) {
treeData.path = bNode.path;
treeData.size = calcTreeNodeSize(treeData);
message.error(`循环引用节点:${bNode.path}`, 4);
return treeData;
}
parsingStack.push(bNode.path);
try {
const subtreePath = settings.workdir + "/" + bNode.path;
const str = fs.readFileSync(subtreePath, "utf8");
treeData = createTreeData(JSON.parse(str).root, settings, treeData.id);
treeData.path = bNode.path;
treeData.size = calcTreeNodeSize(treeData);
} catch (error) {
message.error(`解析子树失败:${bNode.path}`);
console.log("parse subtree:", error);
}
parsingStack.pop();
}
calcTreeNodeSize(treeData);
return treeData;
};
export const createBuildData = (path: string, settings: Settings): BehaviorNodeModel | null => {
try {
path = settings.workdir + "/" + path;
const str = fs.readFileSync(path, "utf8");
let treeModel = JSON.parse(str);
const data = createTreeData(treeModel.root, settings);
refreshNodeId(data);
treeModel.root = createFileData(data, true);
return treeModel as BehaviorNodeModel;
} catch (error) {
console.log("build error:", path, error);
}
return null;
};
export const createFileData = (gNode: GraphNodeModel, includeSubtree?: boolean) => {
const nodeData: BehaviorNodeModel = {
id: Number(gNode.id),
name: gNode.name,
desc: gNode.desc || undefined,
args: gNode.args || undefined,
input: gNode.input || undefined,
output: gNode.output || undefined,
debug: gNode.debug,
path: gNode.path,
};
if (gNode.children && (includeSubtree || !isSubtree(gNode))) {
nodeData.children = [];
gNode.children.forEach((child) => {
nodeData.children.push(createFileData(child, includeSubtree));
});
}
return nodeData;
};
export const getRemoteSettings = () => {
return require("@electron/remote").getGlobal("settings") as Settings;
};
export const fileName2treeName = (filename: string) => {
return path.basename(filename).slice(0, -5);
};
export const createNewTree = (filename: string) => {
const tree: BehaviorTreeModel = {
name: fileName2treeName(filename),
root: {
id: 1,
name: "Sequence",
desc: "新建行为树",
},
};
return tree;
};
export const toBreakWord = (str: string, len: number, char = "\n") => {
var strTemp = "";
var line = 1;
if (str.length <= len) {
return { str, line };
}
while (str.length > len) {
strTemp += str.substr(0, len) + char;
str = str.substr(len, str.length);
line++;
}
strTemp += str;
return {
str: strTemp,
line,
};
};
+37
View File
@@ -0,0 +1,37 @@
/**
* @see https://www.electron.build/configuration/configuration
*/
{
appId: "behavior3editor",
productName: "Behavior3 Editor",
asar: true,
directories: {
output: "release/${version}",
},
files: ["dist-electron", "dist"],
mac: {
artifactName: "behavior3editor_${version}.${ext}",
target: ["dmg", "zip"],
},
win: {
target: [
{
target: "nsis",
arch: ["x64"],
// arch: ["x64", "ia32"],
},
],
artifactName: "behavior3editor_${version}.${ext}",
},
nsis: {
oneClick: false,
perMachine: false,
allowToChangeInstallationDirectory: true,
deleteAppDataOnUninstall: false,
},
publish: {
provider: "generic",
channel: "latest",
url: "https://github.com/electron-vite/electron-vite-react/releases/download/v0.9.9/",
},
}
+11
View File
@@ -0,0 +1,11 @@
/// <reference types="vite-electron-plugin/electron-env" />
declare namespace NodeJS {
interface ProcessEnv {
VSCODE_DEBUG?: 'true'
DIST_ELECTRON: string
DIST: string
/** /dist/ or /public/ */
PUBLIC: string
}
}
+170
View File
@@ -0,0 +1,170 @@
import { app, BrowserWindow, shell, ipcMain } from "electron";
import { release } from "node:os";
import path, { join } from "node:path";
import { update } from "./update";
import * as fs from "fs";
// The built directory structure
//
// ├─┬ dist-electron
// │ ├─┬ main
// │ │ └── index.js > Electron-Main
// │ └─┬ preload
// │ └── index.js > Preload-Scripts
// ├─┬ dist
// │ └── index.html > Electron-Renderer
//
process.env.DIST_ELECTRON = join(__dirname, "../");
process.env.DIST = join(process.env.DIST_ELECTRON, "../dist");
process.env.PUBLIC = process.env.VITE_DEV_SERVER_URL
? join(process.env.DIST_ELECTRON, "../public")
: process.env.DIST;
// Disable GPU Acceleration for Windows 7
if (release().startsWith("6.1")) app.disableHardwareAcceleration();
// Set application name for Windows 10+ notifications
if (process.platform === "win32") app.setAppUserModelId(app.getName());
if (!app.requestSingleInstanceLock()) {
app.quit();
process.exit(0);
}
// Remove electron security warnings
// This warning only shows in development mode
// Read more on https://www.electronjs.org/docs/latest/tutorial/security
// process.env['ELECTRON_DISABLE_SECURITY_WARNINGS'] = 'true'
interface Workspace {
projectPath?: string;
window: BrowserWindow;
}
const preload = "../preload/index.js";
const url = process.env.VITE_DEV_SERVER_URL;
const indexHtml = join(process.env.DIST, "index.html");
const windows: Workspace[] = [];
async function createWindow(projectPath?: string) {
const win = new BrowserWindow({
title: "Behaviour3 Editor",
frame: false,
width: 1280,
height: 800,
minHeight: 600,
minWidth: 800,
closable: true,
minimizable: true,
maximizable: true,
titleBarStyle: "hidden",
titleBarOverlay:
process.platform === "darwin"
? true
: { color: "#0d1117", height: 35, symbolColor: "#7d8590" },
backgroundColor: "#0d1117",
trafficLightPosition: { x: 10, y: 10 },
icon: join(process.env.PUBLIC, "favicon.ico"),
webPreferences: {
preload,
// Warning: Enable nodeIntegration and disable contextIsolation is not secure in production
// Consider using contextBridge.exposeInMainWorld
// Read more on https://www.electronjs.org/docs/latest/tutorial/context-isolation
nodeIntegration: true,
contextIsolation: false,
},
});
const workspace = { projectPath, window: win };
windows.push(workspace);
win.maximizable = true;
if (process.env.VITE_DEV_SERVER_URL) {
// electron-vite-vue#298
win.loadURL(url);
// Open devTool if the app is not packaged
win.webContents.openDevTools();
} else {
win.maximize();
win.loadFile(indexHtml);
}
win.webContents.on("did-finish-load", () => {
win.webContents.setZoomFactor(1);
if (workspace.projectPath) {
win?.webContents.send("open-project", workspace.projectPath);
}
const nextWin = BrowserWindow.getAllWindows().at(-1);
if (nextWin) {
nextWin.focus();
nextWin.webContents.send("refresh-app-men");
}
});
win.on("closed", () => {
const index = windows.findIndex((w) => w.window === win);
windows.splice(index, 1);
});
// Make all links open with the browser, not with the application
win.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith("https:")) shell.openExternal(url);
return { action: "deny" };
});
require("@electron/remote/main").enable(win.webContents);
// Apply electron-updater
// update(win);
}
app.whenReady().then(() => {
require("@electron/remote/main").initialize();
createWindow();
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});
app.on("second-instance", () => {
createWindow();
});
app.on("activate", () => {
const allWindows = BrowserWindow.getAllWindows();
if (allWindows.length) {
allWindows[0].focus();
} else {
createWindow();
}
});
// New window example arg: new windows url
ipcMain.handle("open-win", (event, arg: string | undefined) => {
if (arg) {
let workspace = windows.find((v) => v.projectPath === arg);
if (workspace) {
workspace.window.focus();
return;
}
workspace = windows.find((v) => v.window.webContents.id === event.sender.id);
if (!workspace.projectPath) {
workspace.projectPath = arg;
workspace.window.webContents.send("open-project", arg);
return;
}
}
createWindow(arg);
});
ipcMain.handle("trashItem", (_, arg: string) => {
arg = arg.replace(/\//g, path.sep);
shell.trashItem(arg).catch((e) => console.error(e));
});
+73
View File
@@ -0,0 +1,73 @@
import { app, ipcMain } from 'electron'
import {
type ProgressInfo,
type UpdateDownloadedEvent,
autoUpdater
} from 'electron-updater'
export function update(win: Electron.BrowserWindow) {
// When set to false, the update download will be triggered through the API
autoUpdater.autoDownload = false
autoUpdater.disableWebInstaller = false
autoUpdater.allowDowngrade = false
// start check
autoUpdater.on('checking-for-update', function () { })
// update available
autoUpdater.on('update-available', (arg) => {
win.webContents.send('update-can-available', { update: true, version: app.getVersion(), newVersion: arg?.version })
})
// update not available
autoUpdater.on('update-not-available', (arg) => {
win.webContents.send('update-can-available', { update: false, version: app.getVersion(), newVersion: arg?.version })
})
// Checking for updates
ipcMain.handle('check-update', async () => {
if (!app.isPackaged) {
const error = new Error('The update feature is only available after the package.')
return { message: error.message, error }
}
try {
return await autoUpdater.checkForUpdatesAndNotify()
} catch (error) {
return { message: 'Network error', error }
}
})
// Start downloading and feedback on progress
ipcMain.handle('start-download', (event) => {
startDownload(
(error, progressInfo) => {
if (error) {
// feedback download error message
event.sender.send('update-error', { message: error.message, error })
} else {
// feedback update progress message
event.sender.send('download-progress', progressInfo)
}
},
() => {
// feedback update downloaded message
event.sender.send('update-downloaded')
}
)
})
// Install now
ipcMain.handle('quit-and-install', () => {
autoUpdater.quitAndInstall(false, true)
})
}
function startDownload(
callback: (error: Error | null, info: ProgressInfo) => void,
complete: (event: UpdateDownloadedEvent) => void,
) {
autoUpdater.on('download-progress', info => callback(null, info))
autoUpdater.on('error', error => callback(error, null))
autoUpdater.on('update-downloaded', complete)
autoUpdater.downloadUpdate()
}
+92
View File
@@ -0,0 +1,92 @@
function domReady(condition: DocumentReadyState[] = ["complete", "interactive"]) {
return new Promise((resolve) => {
if (condition.includes(document.readyState)) {
resolve(true);
} else {
document.addEventListener("readystatechange", () => {
if (condition.includes(document.readyState)) {
resolve(true);
}
});
}
});
}
const safeDOM = {
append(parent: HTMLElement, child: HTMLElement) {
if (!Array.from(parent.children).find((e) => e === child)) {
return parent.appendChild(child);
}
},
remove(parent: HTMLElement, child: HTMLElement) {
if (Array.from(parent.children).find((e) => e === child)) {
return parent.removeChild(child);
}
},
};
/**
* https://tobiasahlin.com/spinkit
* https://connoratherton.com/loaders
* https://projects.lukehaas.me/css-loaders
* https://matejkustec.github.io/SpinThatShit
*/
function useLoading() {
const className = `loaders-css__square-spin`;
const styleContent = `
@keyframes square-spin {
25% { transform: perspective(100px) rotateX(180deg) rotateY(0); }
50% { transform: perspective(100px) rotateX(180deg) rotateY(180deg); }
75% { transform: perspective(100px) rotateX(0) rotateY(180deg); }
100% { transform: perspective(100px) rotateX(0) rotateY(0); }
}
.${className} > div {
animation-fill-mode: both;
width: 50px;
height: 50px;
background: #fff;
animation: square-spin 3s 0s cubic-bezier(0.09, 0.57, 0.49, 0.9) infinite;
}
.app-loading-wrap {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #282c34;
z-index: 9;
}
`;
const oStyle = document.createElement("style");
const oDiv = document.createElement("div");
oStyle.id = "app-loading-style";
oStyle.innerHTML = styleContent;
oDiv.className = "app-loading-wrap";
oDiv.innerHTML = `<div class="${className}"><div></div></div>`;
return {
appendLoading() {
safeDOM.append(document.head, oStyle);
safeDOM.append(document.body, oDiv);
},
removeLoading() {
safeDOM.remove(document.head, oStyle);
safeDOM.remove(document.body, oDiv);
},
};
}
// ----------------------------------------------------------------------
const { appendLoading, removeLoading } = useLoading();
domReady().then(appendLoading);
window.onmessage = (ev) => {
ev.data.payload === "removeLoading" && removeLoading();
};
setTimeout(removeLoading, 4999);
+13 -13
View File
@@ -1,14 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>行为树编辑器</title>
</head>
<body>
<div id="root"></div>
<script src="./dist/bundle.js"></script>
</body>
</html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';" /> -->
<title>Behavior3 Editor</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 草鞋没号
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-474
View File
@@ -1,474 +0,0 @@
import {
Menu,
app,
dialog,
BrowserWindow,
MenuItem,
WebContents,
MenuItemConstructorOptions,
} from "electron";
import * as fs from "fs";
import * as Utils from "../common/Utils";
import MainEventType from "../common/MainEventType";
import { MainProcess } from "./MainProcess";
import Settings from "./Settings";
const packageConf = require("../package.json");
export default class AppMenu {
private mainProcess: MainProcess;
private mainWindow: BrowserWindow;
private webContents: WebContents;
private settings: Settings;
private buildDir?: string;
constructor(mainProcess: MainProcess) {
this.mainProcess = mainProcess;
this.mainWindow = mainProcess.mainWindow;
this.webContents = mainProcess.mainWindow.webContents;
this.settings = mainProcess.settings;
}
createMenu() {
const menu: Menu = new Menu();
menu.append(this.createFileMenu());
menu.append(this.createEditMenu());
menu.append(this.createNodeMenu());
menu.append(this.createWorkspaceMenu());
menu.append(this.createToolsMenu());
return menu;
}
private createFileMenu() {
const fileItems: MenuItemConstructorOptions[] = [];
for (let path of this.settings.recentFiles) {
fileItems.push({
label: path,
click: () => {
console.log("open recent file", path);
},
});
}
return new MenuItem({
label: "行为树",
submenu: [
{
label: "新建",
accelerator: "CmdOrCtrl+n",
click: () => {
(async () => {
const res = await dialog.showSaveDialog({
properties: ["showOverwriteConfirmation"],
filters: [{ name: "Json", extensions: ["json"] }],
});
if (!res.canceled) {
const path = res.filePath;
fs.writeFileSync(
path,
JSON.stringify(Utils.createNewTree(path), null, 2)
);
this.webContents.send(MainEventType.CREATE_TREE, path);
}
})();
},
},
{
label: "打开文件",
accelerator: "CmdOrCtrl+O",
click: () => {
(async () => {
const res = await dialog.showOpenDialog({
properties: ["openFile"],
filters: [{ name: "Json", extensions: ["json"] }],
});
if (res.filePaths.length > 0) {
const path = res.filePaths[0];
if (this.settings.recentFiles.indexOf(path) < 0) {
this.settings.recentFiles.unshift(path);
this.settings.save();
this.mainProcess.rebuildMenu();
}
this.webContents.send(MainEventType.OPEN_FILE, path);
}
})();
},
},
{ type: "separator" },
{
label: "最近打开",
submenu: fileItems,
},
{ type: "separator" },
{
label: "保存",
accelerator: "CmdOrCtrl+s",
click: () => {
this.webContents.send(MainEventType.SAVE);
},
},
{
label: "全部保存",
accelerator: "CmdOrCtrl+shift+s",
click: () => {
this.webContents.send(MainEventType.SAVE_ALL);
},
},
{
label: "保存为子树",
click: async () => {
const res = await dialog.showSaveDialog({
defaultPath: this.settings.workdir,
properties: ["showOverwriteConfirmation"],
filters: [{ name: "Json", extensions: ["json"] }],
});
if (!res.canceled) {
const path = res.filePath;
this.webContents.send(MainEventType.SAVE_AS_SUBTREE, path);
}
},
},
{
label: "合并导出Json",
click: () => {
(async () => {
const res = await dialog.showSaveDialog({
properties: ["showOverwriteConfirmation"],
filters: [{ name: "Json", extensions: ["json"] }],
});
if (res.filePath) {
const curWorkspace = this.settings.curWorkspace;
curWorkspace.writeAllTrees(res.filePath, (err) => {
const msg = err ? err : "导出成功";
dialog.showMessageBox({
type: "info",
buttons: ["ok"],
message: msg,
});
});
}
})();
},
},
{
label: "构建",
accelerator: "CmdOrCtrl+b",
click: async () => {
if (!this.buildDir) {
const res = await dialog.showOpenDialog({
properties: ["openDirectory"],
});
if (res.canceled) {
return;
}
this.buildDir = res.filePaths[0];
}
this.webContents.send(MainEventType.BUILD, this.buildDir);
},
},
],
});
}
private createEditMenu() {
return new MenuItem({
label: "编辑",
submenu: [
{
label: "撤销",
accelerator: "CmdOrCtrl+z",
role: "undo",
},
{
label: "恢复",
accelerator: process.platform === "darwin" ? "cmd+shift+z" : "ctrl+y",
role: "redo",
},
{ type: "separator" },
{
label: "新建节点",
accelerator: process.platform === "darwin" ? "enter" : "insert",
click: () => {
this.webContents.send(MainEventType.CREATE_NODE, "unknow");
},
},
{
label: "删除节点",
accelerator: process.platform === "darwin" ? "backspace" : "delete",
click: () => {
this.webContents.send(MainEventType.DELETE_NODE);
},
},
{ type: "separator" },
{
label: "复制节点",
role: "copy",
},
{
label: "粘贴节点",
role: "paste",
},
],
});
}
private createWorkspaceMenu() {
const openWorkspace = (path: string) => {
const curWorkspace = this.settings.curWorkspace;
curWorkspace.setFilepath(path);
curWorkspace.load();
this.settings.pushRecentWorkspace(path);
this.mainProcess.rebuildMenu();
this.webContents.send(
MainEventType.OPEN_DIR,
curWorkspace.getWorkdir(),
curWorkspace.getFilepath()
);
};
const saveToNewPath = () => {
(async () => {
const res = await dialog.showSaveDialog({
properties: ["showOverwriteConfirmation"],
filters: [{ name: "Json", extensions: ["json"] }],
});
if (!res.canceled) {
this.settings.curWorkspace.setFilepath(res.filePath);
this.settings.curWorkspace.save();
openWorkspace(res.filePath);
}
})();
};
const recentItems: MenuItemConstructorOptions[] = [];
for (let path of this.settings.recentWorkspaces) {
recentItems.push({
label: path,
click: () => {
console.log("open recent workspace", path);
openWorkspace(path);
},
});
}
return new MenuItem({
label: "工作区",
submenu: [
{
label: "打开",
click: () => {
(async () => {
const res = await dialog.showOpenDialog({
properties: ["openFile"],
filters: [{ name: "Json", extensions: ["json"] }],
});
if (res.filePaths.length > 0) {
openWorkspace(res.filePaths[0]);
}
})();
},
},
{
label: "保存",
click: () => {
if (this.settings.curWorkspace.getFilepath()) {
this.settings.curWorkspace.save();
} else {
saveToNewPath();
}
},
},
{
label: "另存为",
click: () => {
saveToNewPath();
},
},
{
label: "刷新",
accelerator: "F5",
click: () => {
console.log("reload workspace");
openWorkspace(this.settings.curWorkspace.getFilepath());
},
},
{
label: "最近打开",
submenu: recentItems,
},
{ type: "separator" },
{
label: "打开目录",
accelerator: "CmdOrCtrl+Shift+O",
click: () => {
if (!this.settings.curWorkspace.getNodeConfPath()) {
dialog.showErrorBox("警告", "请先指定节点定义配置!");
return;
}
(async () => {
const res = await dialog.showOpenDialog({
properties: ["openDirectory"],
});
if (res.filePaths.length > 0) {
const curWorkspace = this.settings.curWorkspace;
const path = res.filePaths[0];
curWorkspace.setWorkdir(path);
curWorkspace.save();
this.webContents.send(
MainEventType.OPEN_DIR,
path,
curWorkspace.getFilepath()
);
}
})();
},
},
{
label: "节点定义",
submenu: [
{
label: "选择文件",
click: () => {
(async () => {
const res = await dialog.showOpenDialog({
properties: ["openFile"],
filters: [{ name: "Json", extensions: ["json"] }],
});
if (res.filePaths.length > 0) {
const nodeConfigPath = res.filePaths[0];
this.settings.curWorkspace.setNodeConfPath(nodeConfigPath);
this.mainProcess.rebuildMenu();
}
})();
},
},
{ type: "separator" },
{ label: this.settings.nodeConfPath },
],
},
{ type: "separator" },
{
label: "关闭",
click: () => {
app.quit();
},
},
],
});
}
private createToolsMenu() {
const serverItems: MenuItemConstructorOptions[] = [];
const curServerName = this.settings.serverName;
for (let model of this.settings.curWorkspace.getServers()) {
serverItems.push({
label: `${model.name} ${model.host}`,
type: "checkbox",
checked: curServerName == model.name,
click: () => {
this.settings.serverName = model.name;
this.mainProcess.rebuildMenu();
},
});
}
return new MenuItem({
label: "开发工具",
submenu: [
{
label: "热更",
accelerator: "CmdOrCtrl+R",
click: () => {
this.webContents.send(MainEventType.RELOAD_SERVER);
},
},
{
label: "联调服务器",
submenu: serverItems,
},
{
label: "批处理脚本",
click: () => {
(async () => {
const res = await dialog.showOpenDialog({
properties: ["openFile"],
filters: [{ name: "Javascript", extensions: ["js"] }],
});
if (res.filePaths.length > 0) {
this.webContents.send(MainEventType.BATCH_EXEC, res.filePaths[0]);
}
})();
},
},
{
type: "separator",
},
{
label: "打开控制台",
accelerator: "CmdOrCtrl+Shift+I",
click: (_, browserWindow) => {
browserWindow.webContents.toggleDevTools();
},
},
{
label: "重载编辑器",
click: (_, browserWindow) => {
browserWindow.reload();
},
},
{
type: "separator",
},
{
label: `当前版本:${packageConf.version}`,
},
],
});
}
private createNodeMenu() {
const classifyItems: MenuItemConstructorOptions[] = [];
const map: { [key: string]: MenuItemConstructorOptions } = {};
for (let t of this.settings.nodeClassify) {
let item: MenuItemConstructorOptions = {
id: t.classify,
label: `${t.classify}(${t.desc})`,
submenu: [],
};
classifyItems.push(item);
map[t.classify] = item;
}
const other: MenuItemConstructorOptions = {
id: "other",
label: "其它",
submenu: [],
};
var hasOther = false;
for (let node of this.settings.nodeConfig) {
const item: MenuItemConstructorOptions = {
label: `${node.name}(${node.desc})`,
click: () => {
console.log("create node", node.name);
this.webContents.send(MainEventType.CREATE_NODE, node.name);
},
};
let typeItem = map[node.type];
if (!typeItem) {
typeItem = other;
hasOther = true;
}
if (typeItem.submenu instanceof Menu) {
console.log("typeItem.submenu error", typeItem);
} else {
(typeItem.submenu as MenuItemConstructorOptions[]).push(item);
}
}
if (hasOther) {
classifyItems.push(other);
}
return new MenuItem({
label: "新建节点",
submenu: classifyItems,
});
}
}
-82
View File
@@ -1,82 +0,0 @@
import { app, BrowserWindow, Menu, MenuItem, dialog, nativeTheme } from "electron";
import AppMenu from "./AppMenu";
import Settings from "./Settings";
import MainEventType from "../common/MainEventType";
import electronLocalshortcut from "electron-localshortcut";
// 一些暴露给render-process的全局变量
export interface Global {
settings: Settings;
}
declare var global: Global;
export class MainProcess {
mainWindow: BrowserWindow;
appMenu: AppMenu;
settings: Settings;
constructor() {
nativeTheme.themeSource = "dark";
app.on("ready", () => {
this.createWindow();
this.registerShortcut("CmdOrCtrl+C", () => {
this.mainWindow.webContents.send(MainEventType.COPY_NODE);
});
this.registerShortcut("CmdOrCtrl+V", () => {
this.mainWindow.webContents.send(MainEventType.PASTE_NODE);
});
this.registerShortcut("CmdOrCtrl+Z", () => {
this.mainWindow.webContents.send(MainEventType.UNDO);
});
this.registerShortcut(process.platform === "darwin" ? "Cmd+Shift+Z" : "ctrl+y", () => {
this.mainWindow.webContents.send(MainEventType.REDO);
});
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
app.on("activate", () => {
if (this.mainWindow === null) {
this.createWindow();
}
});
}
registerShortcut(accelerator: string | string[], callback: () => void) {
electronLocalshortcut.register(this.mainWindow, accelerator, callback);
}
createWindow() {
this.settings = new Settings();
global.settings = this.settings;
this.mainWindow = new BrowserWindow({
width: 1280,
height: 800,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
},
// fullscreenable:false,
// maximizable:false
});
require("@electron/remote/main").initialize();
require("@electron/remote/main").enable(this.mainWindow.webContents);
this.mainWindow.maximize();
// mainWindow.webContents.openDevTools();
this.mainWindow.loadFile("index.html");
this.mainWindow.on("closed", function () {
this.mainWindow = null;
});
this.appMenu = new AppMenu(this);
this.rebuildMenu();
}
rebuildMenu() {
Menu.setApplicationMenu(this.appMenu.createMenu());
}
}
export default new MainProcess();
-168
View File
@@ -1,168 +0,0 @@
import * as fs from "fs";
import Workspace from "./Workspace";
import { app } from "electron";
export interface BehaviorNodeClassify {
classify: string;
desc?: string;
}
export interface SettingsModel {
recentWorkspaces?: string[];
recentFiles?: string[];
serverName?: string;
nodeClassify?: BehaviorNodeClassify[];
treesDesc?: { [name: string]: string };
}
const settingPath = app.getPath("userData") + "/settings.json";
console.log("setting path:", settingPath);
const sampleNodeClassify: BehaviorNodeClassify[] = [
{ classify: "Composite", desc: "复合节点" },
{ classify: "Decorator", desc: "修饰节点" },
{ classify: "Condition", desc: "条件节点" },
{ classify: "Action", desc: "行为节点" },
];
export default class Settings {
private model: SettingsModel;
private dirty: boolean = false;
curWorkspace?: Workspace;
constructor() {
this.load();
setInterval(() => {
if (this.dirty) {
fs.writeFileSync(settingPath, JSON.stringify(this.model, null, 2));
this.dirty = false;
}
}, 1000);
}
get nodeConfPath() {
return this.curWorkspace.getNodeConfPath();
}
get recentWorkspaces() {
return this.model.recentWorkspaces;
}
get recentFiles() {
return this.model.recentFiles;
}
get nodeConfig() {
return this.curWorkspace.nodeConf;
}
get nodeClassify() {
return this.model.nodeClassify;
}
get serverModel() {
const servers = this.curWorkspace.getServers();
if (!servers) {
return;
}
for (let server of servers) {
if (server.name == this.model.serverName) {
return server;
}
}
return servers[0];
}
get serverName() {
const servers = this.curWorkspace.getServers();
if (!servers) {
return "";
}
for (let server of servers) {
if (server.name == this.model.serverName) {
return this.model.serverName;
}
}
if (servers[0]) {
return servers[0].name;
} else {
return "";
}
}
set serverName(name: string) {
this.model.serverName = name;
this.save();
}
set(config: SettingsModel) {
this.model = {
...this.model,
...config,
};
this.save();
}
load() {
if (fs.existsSync(settingPath)) {
const str = fs.readFileSync(settingPath, "utf8");
this.model = JSON.parse(str);
if (!this.model.treesDesc) {
this.model.treesDesc = {};
this.save();
}
} else {
this.model = {
recentWorkspaces: ["sample/workspace.json"],
recentFiles: [],
nodeClassify: sampleNodeClassify,
treesDesc: {},
};
this.save();
}
this.curWorkspace = new Workspace(this.model.recentWorkspaces[0]);
this.curWorkspace.load();
}
save() {
this.dirty = true;
}
get workdir() {
return this.curWorkspace.getWorkdir();
}
getNodeConf(name: string) {
return this.curWorkspace.getNodeConf(name);
}
hasNodeConf(name: string) {
return this.curWorkspace.hasNodeConf(name);
}
pushRecentWorkspace(path: string) {
var list = this.model.recentWorkspaces;
if (list.indexOf(path) >= 0) {
list = list.filter((value) => value !== path);
}
list.unshift(path);
while (list.length > 10) {
list.pop();
}
this.model.recentWorkspaces = list;
this.save();
}
getTreeDesc(name: string) {
const key = this.curWorkspace.getFilepath() + " " + name;
var desc = this.model.treesDesc[key];
if (!desc) {
const str = fs.readFileSync(name, "utf8");
const tree = JSON.parse(str);
desc = tree.desc ? tree.desc : "";
this.model.treesDesc[key] = desc;
this.save();
}
return desc;
}
setTreeDesc(name: string, desc?: string) {
const key = this.curWorkspace.getFilepath() + " " + name;
this.model.treesDesc[key] = desc;
}
}
-161
View File
@@ -1,161 +0,0 @@
import * as fs from "fs";
import { BehaviorNodeTypeModel } from "../common/BehaviorTreeModel";
import * as glob from "glob";
import * as path from "path";
export interface ServerModel {
name: string;
host: string;
}
export interface WorkspaceModel {
isRelative?: boolean;
nodeConfPath: string;
workdir: string;
servers?: ServerModel[];
}
const unknowNodeType: BehaviorNodeTypeModel = {
name: "unknow",
desc: "新建节点",
type: "Action",
};
export default class Workspace {
private filepath: string;
private nodeConfPath: string;
private workdir: string;
private model: WorkspaceModel;
private name2conf: { [name: string]: BehaviorNodeTypeModel } = {};
private types: BehaviorNodeTypeModel[] = [];
constructor(filepath: string) {
this.filepath = filepath;
}
load() {
if (!this.filepath) {
return;
}
try {
const str = fs.readFileSync(this.filepath, "utf8");
const model = JSON.parse(str) as WorkspaceModel;
if (model.isRelative) {
const root = path.dirname(this.filepath);
this.nodeConfPath = path.join(root, model.nodeConfPath);
this.workdir = path.join(root, model.workdir);
} else {
this.nodeConfPath = model.nodeConfPath;
this.workdir = model.workdir;
}
this.model = model;
this.initNodeConf();
} catch (error) {
console.log(error);
}
}
private initNodeConf() {
if (!this.nodeConfPath) {
return;
}
const types: BehaviorNodeTypeModel[] = JSON.parse(
fs.readFileSync(this.nodeConfPath, "utf8")
);
this.name2conf = {};
types.forEach((t) => {
this.name2conf[t.name] = t;
});
this.types = types;
}
getNodeConfPath() {
return this.nodeConfPath;
}
setNodeConfPath(path: string) {
this.nodeConfPath = path;
this.initNodeConf();
}
getWorkdir(): string {
return this.workdir;
}
setWorkdir(workdir: string) {
this.workdir = workdir;
}
getServers() {
if (this.model && this.model.servers) {
return this.model.servers;
} else {
return [];
}
}
getModel() {
return this.model;
}
writeAllTrees(outFilePath: string, cb?: (err: string) => void) {
if (!fs.existsSync(this.getWorkdir())) {
cb && cb("请先打开工作区");
return;
}
glob.glob(path.join(this.getWorkdir(), "**/*.json"), function (err, files) {
if (err) {
console.error(err);
cb && cb(err.message);
return;
}
let result = new Array();
files.forEach((file) => {
const data = fs.readFileSync(file, "utf8");
result.push(JSON.parse(data));
});
fs.writeFileSync(outFilePath, JSON.stringify(result, null, 2));
cb && cb(null);
});
}
save(filepath?: string) {
if (filepath) {
this.filepath = filepath;
}
if (!this.filepath) {
return;
}
fs.writeFileSync(
this.filepath,
JSON.stringify(
{
nodeConfPath: this.nodeConfPath,
workdir: this.workdir,
},
null,
2
)
);
}
get nodeConf() {
return this.types;
}
getFilepath() {
return this.filepath;
}
setFilepath(filepath: string) {
this.filepath = filepath;
}
getNodeConf(name: string) {
return this.name2conf[name] || unknowNodeType;
}
hasNodeConf(name: string) {
return !!this.name2conf[name];
}
}
+7911 -7223
View File
File diff suppressed because it is too large. Load diff
+68 -51
View File
@@ -1,69 +1,86 @@
{
"name": "behavior3editor",
"version": "0.2.3",
"version": "1.0.0",
"main": "dist-electron/main/index.js",
"description": "行为树编辑器",
"main": "./dist/main.js",
"author": "zhandouxiaojiji & codetypes",
"license": "MIT",
"private": true,
"debug": {
"env": {
"VITE_DEV_SERVER_URL": "http://127.0.0.1:7777/"
}
},
"scripts": {
"prestart": "webpack --config webpack.config.js",
"start": "electron .",
"build": "electron-packager ./ behavior3editor --platform=win32 --arch=x64 --out=./build --overwrite",
"build-mac": "electron-packager ./ behavior3editor --platform=darwin --arch=universal --out=./build --overwrite",
"dist-mac": "npm run prestart && electron-builder --dir && electron-builder --mac",
"dist": "npm run prestart && electron-builder --dir && electron-builder --win --x64"
"dev": "vite",
"build": "tsc && vite build && electron-builder",
"tsc-check:": "tsc --noEmit",
"postinstall": "copy-files-from-to"
},
"repository": {
"type": "git",
"url": "git+ssh://git@github.com/zhandouxiaojiji/behavior3editor.git"
},
"author": "zhandouxiaojiji",
"license": "MIT",
"bugs": {
"url": "https://github.com/zhandouxiaojiji/behavior3editor/issues"
},
"build": {
"appId": "com.example.app",
"directories": {
"output": "build"
}
},
"homepage": "https://github.com/zhandouxiaojiji/behavior3editor#readme",
"devDependencies": {
"@types/electron-localshortcut": "^3.1.3",
"@types/glob": "^8.1.0",
"@types/node": "^20.11.25",
"@types/rimraf": "^3.0.2",
"copy-webpack-plugin": "^12.0.2",
"css-loader": "^6.10.0",
"electron": "^29.1.1",
"electron-builder": "^24.13.3",
"electron-localshortcut": "^3.2.1",
"electron-packager": "^17.1.2",
"file-loader": "^6.2.0",
"less": "^4.2.0",
"less-loader": "^12.2.0",
"node-fetch": "^3.3.2",
"source-map-loader": "^5.0.0",
"style-loader": "^3.3.4",
"ts-loader": "^9.5.1",
"typescript": "^5.3.3",
"webpack": "^5.90.3",
"webpack-cli": "^5.1.4"
"@stylistic/eslint-plugin-js": "^1.7.0",
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
"@typescript-eslint/eslint-plugin": "^7.3.1",
"@typescript-eslint/parser": "^7.3.1",
"@vitejs/plugin-react": "^3.1.0",
"copy-files-from-to": "^3.9.1",
"electron": "^23.2.0",
"electron-builder": "^23.6.0",
"eslint": "^8.57.0",
"eslint-plugin-react-refresh": "^0.4.6",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"sass": "^1.60.0",
"typescript": "^5.0.2",
"vite": "^4.1.4",
"vite-electron-plugin": "^0.8.2",
"vite-plugin-electron": "^0.11.1",
"vite-plugin-electron-renderer": "^0.13.12"
},
"dependencies": {
"@ant-design/icons": "^4.3.0",
"@antv/g6": "^3.8.5",
"@ant-design/icons": "^5.3.3",
"@antv/g6": "^4.8.24",
"@electron/remote": "^2.1.2",
"@types/react": "^16.9.51",
"@types/react-dom": "^16.9.8",
"antd": "^4.6.6",
"lodash": "^4.17.20",
"react": "^16.13.1",
"react-confirm-alert": "^2.6.2",
"react-dom": "^16.13.1",
"react-markdown": "^5.0.2",
"rimraf": "^3.0.2",
"sanitize-filename": "^1.6.3",
"shineout": "^1.6.1",
"sync-request": "^6.1.0"
}
"@types/electron-localshortcut": "^3.1.3",
"@types/node": "^20.11.27",
"ahooks": "^3.7.10",
"antd": "^5.16.0",
"electron-localshortcut": "^3.2.1",
"electron-updater": "^5.3.0",
"i18next": "^23.10.1",
"i18next-browser-languagedetector": "^7.2.0",
"i18next-http-backend": "^2.5.0",
"json-stable-stringify": "^1.1.1",
"react-hotkeys-hook": "^4.5.0",
"react-i18next": "^14.1.0",
"react-icons": "^5.0.1",
"react-markdown": "^9.0.1",
"react-merge-refs": "^2.1.1",
"ts-key-enum": "^2.0.12",
"use-force-update": "^1.0.11",
"usehooks-ts": "^3.0.1",
"zustand": "^4.5.2"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"copyFilesSettings": {
"whenFileExists": "overwrite"
},
"copyFiles": [
{
"from": "pre-commit",
"to": ".git/hooks/pre-commit"
}
]
}
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
echo -e "\nValidating Javascript:\n"
# Check for eslint
which tsc &> /dev/null
if [[ "$?" == 1 ]]; then
echo -e "\t\033[41mPlease install tsc\033[0m"
exit 1
fi
tsc --noEmit
if [[ "$?" == 0 ]]; then
echo -e "\033[42mCOMMIT SUCCEEDED\033[0m\n"
exit 0
else
echo -e "\033[41mCOMMIT FAILED:\033[0m Your commit contains files that should pass ESLint but do not. Please fix the ESLint errors and try again.\n"
exit 1
fi
+55
View File
@@ -0,0 +1,55 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="none" x="0px" y="0px" width="128px" height="128px" viewBox="0 0 128 128">
<defs>
<g id="Layer1_0_FILL">
<path fill="#2F3242" stroke="none" d="
M 128 64
Q 128 37.55 109.25 18.75 90.5 0 64 0 37.5 0 18.75 18.75 0 37.5 0 64 0 90.5 18.75 109.25 37.5 128 64 128 90.5 128 109.25 109.25 128 90.5 128 64 Z"/>
</g>
<g id="Layer0_0_FILL">
<path fill="#A2ECFB" stroke="none" d="
M 21.05 10.95
Q 21.7 10.95 22.2 10.5 22.65 10.05 22.65 9.4
L 22.65 4.7
Q 22.65 4.05 22.2 3.6 21.7 3.15 21.05 3.15
L 16.4 3.15
Q 15.75 3.15 15.25 3.6 14.8 4.05 14.8 4.7
L 14.8 6.25 14.05 6.25
Q 12.75 6.25 11.8 7.15 10.9 8.1 10.9 9.4
L 10.9 11.75 7.8 11.75 7.8 10.95
Q 7.8 10.3 7.35 9.85 6.9 9.4 6.25 9.4
L 3.1 9.4
Q 2.45 9.4 2 9.85 1.55 10.3 1.55 10.95
L 1.55 14.1
Q 1.55 14.75 2 15.2 2.45 15.65 3.1 15.65
L 6.25 15.65
Q 6.9 15.65 7.35 15.2 7.8 14.75 7.8 14.1
L 7.8 13.3 10.9 13.3 10.9 15.65
Q 10.9 16.95 11.8 17.85 12.75 18.75 14.05 18.75
L 14.8 18.75 14.8 20.35
Q 14.8 21 15.25 21.45 15.75 21.9 16.4 21.9
L 21.05 21.9
Q 21.7 21.9 22.2 21.45 22.65 21 22.65 20.35
L 22.65 15.65
Q 22.65 15 22.2 14.55 21.7 14.1 21.05 14.1
L 16.4 14.1
Q 15.75 14.1 15.25 14.55 14.8 15 14.8 15.65
L 14.8 17.2 14.05 17.2
Q 13.4 17.2 12.95 16.75 12.5 16.3 12.5 15.65
L 12.5 9.4
Q 12.5 8.75 12.95 8.3 13.4 7.85 14.05 7.85
L 14.8 7.85 14.8 9.4
Q 14.8 10.05 15.25 10.5 15.75 10.95 16.4 10.95
L 21.05 10.95 Z"/>
</g>
</defs>
<g transform="matrix( 1, 0, 0, 1, 0,0) ">
<use xlink:href="#Layer1_0_FILL"/>
</g>
<g transform="matrix( 3.75091552734375, 0, 0, 3.75091552734375, 12.6,15.75) ">
<use xlink:href="#Layer0_0_FILL"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 614 B

After

Width:  |  Height:  |  Size: 614 B

File renamed without changes.

Before

Width:  |  Height:  |  Size: 857 B

After

Width:  |  Height:  |  Size: 857 B

File renamed without changes.

Before

Width:  |  Height:  |  Size: 812 B

After

Width:  |  Height:  |  Size: 812 B

File renamed without changes.

Before

Width:  |  Height:  |  Size: 833 B

After

Width:  |  Height:  |  Size: 833 B

File renamed without changes.

Before

Width:  |  Height:  |  Size: 772 B

After

Width:  |  Height:  |  Size: 772 B

File renamed without changes.

Before

Width:  |  Height:  |  Size: 663 B

After

Width:  |  Height:  |  Size: 663 B

File renamed without changes.

Before

Width:  |  Height:  |  Size: 694 B

After

Width:  |  Height:  |  Size: 694 B

+129
View File
@@ -0,0 +1,129 @@
{
"about": "About Behavior3",
"actuaSize": "Actual Size",
"build": "Build",
"batch": "Batch Script",
"buildCompleted": "Build completed",
"cancel": "Cancel",
"copy": "Copy",
"createProject": "Create Project",
"delete": "Delete",
"deleteNode": "Delete Node",
"devTools": "Toggle Developer Tools",
"donotSave": "Don't Save",
"duplicate": "Duplicate",
"editSubtree": "Edit Subtree",
"forceReload": "Force Reload",
"fullscreen": "Toggle Full Screen",
"hide": "Hide Behavior3",
"hideOthers": "Hide Others",
"insertNode": "Insert Node",
"language": "Language",
"moveToTrash": "Move to Trash",
"newFile": "New File...",
"newFolder": "New Folder...",
"newWindow": "New Window",
"open": "Open...",
"openProject": "Open Project",
"paste": "Paste",
"quit": "Quit Behavior3",
"recent": "Recent",
"redo": "Redo",
"reload": "Reload",
"rename": "Rename...",
"replace": "Replace",
"reportIssue": "Report Issue",
"revealFileOnMac": "Reveal in Finder",
"revealFileOnWindows": "Reveal in File Explorer",
"save": "Save",
"saveAll": "Save All",
"saveAs": "Save As...",
"saveAsSubtree": "Save As Subtree",
"searchFile": "Search File",
"services": "Services",
"start": "Start",
"undo": "Undo",
"unhide": "Show All",
"zoomIn": "Zoom In",
"zoomOut": "Zoom Out",
"menu.edit": "Edit",
"menu.file": "File",
"menu.view": "View",
"menu.window": "Window",
"menu.help": "Help",
"workspace.saveAllOnClose": "Do you want to save the changes to the following {{count}} files?",
"workspace.saveOnClose": "Do you want to save the changes you made to '{{name}}'?",
"explorer.deleteFile": "Are you sure you want delete '{{name}}'?",
"explorer.deleteFolder": "Are you sure you want delete '{{name}}' and its contents?",
"explorer.replaceFile": "Are you sure you want replace '{{name}}'?",
"explorer.restoreFileInfo": "You can restore this file from the Trash.",
"explorer.title": "Explorer",
"tree.overview": "Overview",
"tree.name": "Tree name",
"tree.desc": "Tree desc",
"regnode.input": "Input: ",
"regnode.output": "Output: ",
"regnode.args": "Args: ",
"regnode.mark": "Mark: ",
"regnode.subtree": "Subtree: ",
"node.args": "Const arguments",
"node.codePlaceholder": "a.b * 3 > 2",
"node.debug": "Debug",
"node.deleteRootNodeDenied": "Unable to delete the root node",
"node.editSubtreeDenied": "Unable to edit the subtree node",
"node.desc": "Node desc",
"node.fileRequired": "{{field}} field is required",
"node.id": "Node id",
"node.inputVariable": "Input variables",
"node.name": "Node name",
"node.noNodeSelected": "No node selected",
"node.outputVariable": "Output variables",
"node.pasteDataError": "Paste data error",
"node.rootNodeAsSubtree": "Root node cann't be save as subtree",
"node.subtree": "Subtree",
"node.subtreePathError": "You should save the subtree inside the current workspace",
"node.unknown.desc": "New node",
"node.alwaysFail.desc": "Always return failure",
"node.alwaysFail.doc": "+ Only one child node, multiple child nodes will be executed first\n+ If any child node fails, return failure",
"node.alwaysSuccess.desc": "Always return success",
"node.alwaysSuccess.doc": "+ Only one child node, multiple child nodes will be executed first\n+ If any child node fails, return success",
"node.check.arg1.desc": "Expression",
"node.check.desc": "Evaluate expression",
"node.check.doc": "+ Do simple calculation, return success or failure",
"node.clear.desc": "Clear variable",
"node.clear.output1": "Variable name",
"node.forEach.desc": "Iterate array",
"node.forEach.input1": "Array",
"node.forEach.output1": "Variable name",
"node.forEach.doc": "+ Before executing each child node, set the current variable to the output\n+ Execute all child nodes\n+ Return success or running",
"node.getTime.desc": "Get current time",
"node.getTime.output1": "Current time",
"node.isNull.desc": "Check variable is null",
"node.isNull.input1": "Variable",
"node.log.desc": "Print log",
"node.log.arg1.desc": "Log content",
"node.loop.desc": "Loop execute",
"node.loop.arg1.desc": "Loop times",
"node.loop.input1": "Loop times",
"node.not.desc": "Invert",
"node.not.doc": "+ Invert the return value",
"node.notNull.desc": "Check variable is not null",
"node.notNull.input1": "Variable",
"node.once.desc": "Execute once",
"node.once.doc": "+ Execute all child nodes\n+ Return success or running",
"node.parallel.desc": "Parallel execute",
"node.parallel.doc": "+ Execute all child nodes\n+ Return success",
"node.selector.desc": "Selector execute",
"node.selector.doc": "+ Execute all child nodes\n+ Return success or failure",
"node.sequence.desc": "Sequence execute",
"node.sequence.doc": "+ Execute all child nodes\n+ Return success or failure",
"node.wait.desc": "Wait",
"node.wait.arg1.desc": "Wait time"
}
+129
View File
@@ -0,0 +1,129 @@
{
"about": "关于 Behavior3",
"actualSize": "实际大小",
"build": "构建",
"batch": "批处理脚本",
"buildCompleted": "构建完成",
"cancel": "取消",
"copy": "复制",
"createProject": "新建项目",
"delete": "删除",
"deleteNode": "删除节点",
"devTools": "切换开发者工具",
"donotSave": "不保存",
"duplicate": "复制",
"editSubtree": "编辑子树",
"forceReload": "强制重新加载",
"fullscreen": "切换全屏",
"hide": "隐藏 Behavior3",
"hideOthers": "隐藏其它",
"insertNode": "插入节点",
"language": "语言",
"moveToTrash": "移至回收站",
"newFile": "新建行为树...",
"newFolder": "新建目录...",
"newWindow": "新建窗口",
"open": "打开...",
"openProject": "打开项目",
"paste": "粘贴",
"quit": "退出 Behavior3",
"recent": "最近打开",
"redo": "重做",
"reload": "重新加载",
"rename": "重命名...",
"replace": "替换",
"reportIssue": "问题反馈",
"revealFileOnMac": "在 Finder 中显示",
"revealFileOnWindows": "在文件管理器中打开",
"save": "保存",
"saveAll": "保存全部",
"saveAs": "另存为...",
"saveAsSubtree": "另存为子树",
"searchFile": "搜索文件",
"services": "服务",
"start": "开始",
"undo": "撤销",
"unhide": "显示所有",
"zoomIn": "放大",
"zoomOut": "缩小",
"menu.file": "文件",
"menu.edit": "编辑",
"menu.view": "视图",
"menu.window": "窗口",
"menu.help": "帮助",
"workspace.saveAllOnClose": "你需要保存对以下 {{count}} 个文件的修改吗?",
"workspace.saveOnClose": "你需要保存对文件 {{name}} 的修改吗?",
"explorer.deleteFile": "你确定要删除文件 '{{name}}'?",
"explorer.deleteFolder": "你确定要删除目录 '{{name}}'?",
"explorer.replaceFile": "你确定要替换文件 '{{name}}'?",
"explorer.restoreFileInfo": "你可以从回收站恢复此文件。",
"explorer.title": "资源浏览",
"tree.overview": "概况",
"tree.name": "行为树",
"tree.desc": "说明",
"regnode.input": "输入:",
"regnode.output": "输出:",
"regnode.args": "参数:",
"regnode.mark": "备注:",
"regnode.subtree": "子树:",
"node.args": "常量参数",
"node.codePlaceholder": "a.b * 3 > 2",
"node.debug": "调试",
"node.deleteRootNodeDenied": "不能删除根节点",
"node.editSubtreeDenied": "不能编辑子树节点",
"node.desc": "节点说明",
"node.fileRequired": "字段<b>{{field}}</b>必填",
"node.id": "节点标识",
"node.inputVariable": "输入变量",
"node.name": "节点名称",
"node.noNodeSelected": "未选择任务节点",
"node.outputVariable": "输出变量",
"node.pasteDataError": "粘贴数据出错",
"node.rootNodeAsSubtree": "根节点不能保存为子树",
"node.subtree": "子树",
"node.subtreePathError": "子树必须保存在当前工作区的目录内",
"node.unknown.desc": "新建节点",
"node.alwaysFail.desc": "始终返回失败",
"node.alwaysFail.doc": "+ 只能有一个子节点,多个仅执行第一个\n+ 不管子节点是否成功都返回失败",
"node.alwaysSuccess.desc": "始终返回成功",
"node.alwaysSuccess.doc": "+ 只能有一个子节点,多个仅执行第一个\n+ 不管子节点是否成功都返回成功",
"node.check.arg1.desc": "表达式",
"node.check.desc": "对表达式进行求值",
"node.check.doc": "+ 做简单数值公式判定,返回成功或失败",
"node.clear.desc": "清除变量",
"node.clear.output1": "清除的变量名",
"node.forEach.desc": "遍历数组",
"node.forEach.input1": "数组",
"node.forEach.output1": "变量名",
"node.forEach.doc": "+ 每次执行子节点前会设置当前遍历到的变量\n+ 会执行所有子节点\n+ 永远返回成功/正在运行",
"node.getTime.desc": "获取当前时间",
"node.getTime.output1": "当前时间",
"node.isNull.desc": "判断变量是否为空",
"node.isNull.input1": "判断的变量",
"node.log.desc": "打印日志",
"node.log.arg1.desc": "日志内容",
"node.loop.desc": "循环执行",
"node.loop.arg1.desc": "循环次数",
"node.loop.input1": "循环次数",
"node.not.desc": "取反",
"node.not.doc": "+ 将子节点的返回值取反",
"node.notNull.desc": "判断变量是否存在",
"node.notNull.input1": "判断的变量",
"node.once.desc": "只执行一次",
"node.once.doc": "+ 可以接多个子节点,子节点默认全部执行\n+ 被打断后该节点后的子节点依旧不会执行\n+ 该节点执行后永远返回成功",
"node.parallel.desc": "并行执行",
"node.parallel.doc": "+ 执行所有子节点并返回成功",
"node.selector.desc": "选择执行",
"node.selector.doc": "+ 一直往下执行,有子节点返回成功则返回成功,若全部节点返回失败则返回失败\n+ 子节点是或 (OR) 的关系",
"node.sequence.desc": "顺序执行",
"node.sequence.doc": "+ 一直往下执行,只有当所有子节点都返回成功, 才返回成功\n+ 子节点是与(AND)的关系",
"node.wait.desc": "等待",
"node.wait.arg1.desc": "等待时间"
}
+55
View File
@@ -0,0 +1,55 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="none" x="0px" y="0px" width="128px" height="128px" viewBox="0 0 128 128">
<defs>
<g id="Layer1_0_FILL">
<path fill="#2F3242" stroke="none" d="
M 128 64
Q 128 37.55 109.25 18.75 90.5 0 64 0 37.5 0 18.75 18.75 0 37.5 0 64 0 90.5 18.75 109.25 37.5 128 64 128 90.5 128 109.25 109.25 128 90.5 128 64 Z"/>
</g>
<g id="Layer0_0_FILL">
<path fill="#A2ECFB" stroke="none" d="
M 21.05 10.95
Q 21.7 10.95 22.2 10.5 22.65 10.05 22.65 9.4
L 22.65 4.7
Q 22.65 4.05 22.2 3.6 21.7 3.15 21.05 3.15
L 16.4 3.15
Q 15.75 3.15 15.25 3.6 14.8 4.05 14.8 4.7
L 14.8 6.25 14.05 6.25
Q 12.75 6.25 11.8 7.15 10.9 8.1 10.9 9.4
L 10.9 11.75 7.8 11.75 7.8 10.95
Q 7.8 10.3 7.35 9.85 6.9 9.4 6.25 9.4
L 3.1 9.4
Q 2.45 9.4 2 9.85 1.55 10.3 1.55 10.95
L 1.55 14.1
Q 1.55 14.75 2 15.2 2.45 15.65 3.1 15.65
L 6.25 15.65
Q 6.9 15.65 7.35 15.2 7.8 14.75 7.8 14.1
L 7.8 13.3 10.9 13.3 10.9 15.65
Q 10.9 16.95 11.8 17.85 12.75 18.75 14.05 18.75
L 14.8 18.75 14.8 20.35
Q 14.8 21 15.25 21.45 15.75 21.9 16.4 21.9
L 21.05 21.9
Q 21.7 21.9 22.2 21.45 22.65 21 22.65 20.35
L 22.65 15.65
Q 22.65 15 22.2 14.55 21.7 14.1 21.05 14.1
L 16.4 14.1
Q 15.75 14.1 15.25 14.55 14.8 15 14.8 15.65
L 14.8 17.2 14.05 17.2
Q 13.4 17.2 12.95 16.75 12.5 16.3 12.5 15.65
L 12.5 9.4
Q 12.5 8.75 12.95 8.3 13.4 7.85 14.05 7.85
L 14.8 7.85 14.8 9.4
Q 14.8 10.05 15.25 10.5 15.75 10.95 16.4 10.95
L 21.05 10.95 Z"/>
</g>
</defs>
<g transform="matrix( 1, 0, 0, 1, 0,0) ">
<use xlink:href="#Layer1_0_FILL"/>
</g>
<g transform="matrix( 3.75091552734375, 0, 0, 3.75091552734375, 12.6,15.75) ">
<use xlink:href="#Layer0_0_FILL"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

-28
View File
@@ -1,28 +0,0 @@
.editor {
width : 100%;
height : 100%;
overflow: hidden;
}
.editorBd {
width : 100%;
height : 100%;
display: flex;
}
.editorContent {
/* min-width: 500px; */
height : 100%;
}
.editorSidebar {
/* min-width: 500px; */
height : 100%;
overflow-y: auto;
}
.mind {
width : 100%;
height: 800px;
}
-349
View File
@@ -1,349 +0,0 @@
import React from "react";
import {
Card,
Divider,
Form,
Input,
AutoComplete,
Select,
Switch,
InputNumber,
notification,
message,
} from "antd";
import { INode } from "@antv/g6/lib/interface/item";
import {
BehaviorNodeModel,
BehaviorNodeTypeModel,
ArgsDefType,
} from "../../common/BehaviorTreeModel";
import Settings from "../../main-process/Settings";
import { FormInstance } from "antd/lib/form";
import Markdown from "react-markdown";
const { Item } = Form;
const { Option } = Select;
interface NodePanelProps {
isSubtreeNode: boolean;
model: BehaviorNodeModel;
settings: Settings;
updateNode: (id: string, forceUpdate: boolean) => void;
pushUndoStack: () => void;
}
interface NodePanelState {}
export default class NodePanel extends React.Component<NodePanelProps> {
formRef = React.createRef<FormInstance>();
componentDidUpdate() {
this.formRef.current.resetFields();
this.formRef.current.setFieldsValue(this.getInitialValues());
}
getInitialValues() {
const { model } = this.props;
const initialValues: any = {
name: model.name,
desc: model.desc,
debug: model.debug,
path: model.path,
customArgs: model.args ? JSON.stringify(model.args, null, " ") : "",
};
if (model.args) {
for (let k in model.args) {
initialValues[`args.${k}`] = model.args[k];
}
}
if (model.input) {
model.input.forEach((v, i) => {
initialValues[`input.${i}`] = v;
});
}
if (model.output) {
model.output.forEach((v, i) => {
initialValues[`output.${i}`] = v;
});
}
return initialValues;
}
onFinish = (values: any) => {
console.log("Success:", values);
const { updateNode, pushUndoStack, model, settings } = this.props;
const conf = settings.getNodeConf(values.name);
if (!conf) {
notification.warn({ message: `节点${values.name}未定义` });
return;
}
var args: any = {};
if (values.customArgs) {
try {
args = JSON.parse(values.customArgs);
} catch (e) {
message.warn(`您输入的自定义参数不符合json格式${values.customArgs}`);
return;
}
}
pushUndoStack();
var forceUpdate = false;
if (model.name != values.name) {
model.name = values.name;
forceUpdate = true;
}
model.desc = values.desc;
model.debug = values.debug;
if (model.path !== values.path) {
model.path = values.path;
forceUpdate = true;
}
if (conf.args) {
conf.args.forEach((e) => {
const k = "args." + e.name;
if (e.type.indexOf("number") > 0) {
args[e.name] = Number(values[k]);
} else {
args[e.name] = values[k];
}
});
}
model.args = args;
this.formRef.current.setFieldsValue({
customArgs: model.args ? JSON.stringify(model.args, null, " ") : "",
});
if (conf.input) {
model.input = [];
conf.input.forEach((e, i) => {
model.input.push(values["input." + i] || "");
});
} else {
model.input = null;
}
if (conf.output) {
model.output = [];
conf.output.forEach((e, i) => {
model.output.push(values["output." + i] || "");
});
} else {
model.output = null;
}
if (forceUpdate) {
this.forceUpdate();
}
updateNode(model.id.toString(), forceUpdate);
};
onFinishFailed = (errorInfo: any) => {
console.log("Failed:", errorInfo);
};
handleSubmit = () => {
console.log("handleSubmit");
this.formRef.current.submit();
};
render() {
const { model, settings, isSubtreeNode } = this.props;
const conf = settings.getNodeConf(model.name);
const title = conf.desc;
const options: any = [];
settings.nodeConfig.map((e) => {
options.push({ label: `${e.name}(${e.desc})`, value: e.name });
});
const layout = {
labelCol: { span: 6 },
wrapperCol: { span: 18 },
};
return (
<Card title={title} style={{ height: window.screen.height - 100, overflow: "auto" }}>
<Form
{...layout}
name="basic"
onFinish={this.onFinish}
initialValues={this.getInitialValues()}
ref={this.formRef}
>
<Item label="节点id">
<Input value={model.id} disabled={true} />
</Item>
<Item label="节点名称" name="name">
<AutoComplete
disabled={isSubtreeNode}
options={options}
onBlur={this.handleSubmit}
filterOption={(inputValue: string, option: any) => {
return (
option.label.toUpperCase().indexOf(inputValue.toUpperCase()) !==
-1
);
}}
/>
</Item>
<Item label="节点说明" name="desc">
<Input disabled={isSubtreeNode} onBlur={this.handleSubmit} />
</Item>
<Item label="调试开关" name="debug" valuePropName="checked">
<Switch disabled={isSubtreeNode} onChange={this.handleSubmit} />
</Item>
<Item label="子树" name="path">
<Input disabled={isSubtreeNode && !model.path} onBlur={this.handleSubmit} />
</Item>
<Markdown source={conf.doc} />
{this.renderInputs(conf, isSubtreeNode)}
{this.renderArgs(conf, isSubtreeNode)}
{this.renderOutputs(conf, isSubtreeNode)}
</Form>
</Card>
);
}
renderArgs(conf: BehaviorNodeTypeModel, isSubtreeNode: boolean) {
if (!conf || !conf.args || conf.args.length == 0) {
return null;
}
// 普通参数
const normalArgs = (e: ArgsDefType) => {
const required = e.type.indexOf("?") == -1;
if (e.type.indexOf("string") >= 0) {
return <Input disabled={isSubtreeNode} onBlur={this.handleSubmit} />;
} else if (e.type.indexOf("int") >= 0) {
return (
<InputNumber
disabled={isSubtreeNode}
precision={0}
style={{ width: "100%" }}
onBlur={this.handleSubmit}
/>
);
} else if (e.type.indexOf("float") >= 0) {
return (
<InputNumber
disabled={isSubtreeNode}
style={{ width: "100%" }}
onBlur={this.handleSubmit}
/>
);
} else if (e.type.indexOf("boolean") >= 0) {
return <Switch disabled={isSubtreeNode} onChange={this.handleSubmit} />;
} else if (e.type.indexOf("code") >= 0) {
return (
<Input
disabled={isSubtreeNode}
onBlur={this.handleSubmit}
placeholder={"表达式"}
/>
);
} else if (e.type.indexOf("enum") >= 0) {
return (
<Select
disabled={isSubtreeNode}
style={{ width: 120 }}
onChange={this.handleSubmit}
>
{e.options.map((e) => {
return (
<Option key={e.name} value={e.value}>
{e.name}
</Option>
);
})}
</Select>
);
}
};
// 自定义参数
const customArgs = () => {
return (
<Item name="customArgs" label="自定义" key="customArgs">
<Input.TextArea
disabled={isSubtreeNode}
onBlur={this.handleSubmit}
style={{ minHeight: 100 }}
/>
</Item>
);
};
return (
<div>
<Divider orientation="left">
<h3>常量参数</h3>
</Divider>
{conf &&
conf.args &&
conf.args.map((e, i: number) => {
const required = e.type.indexOf("?") == -1;
return (
<Item
initialValue={e.default}
name={`args.${e.name}`}
label={e.desc}
key={`args.${e.name}`}
valuePropName={
e.type.indexOf("boolean") >= 0 ? "checked" : undefined
}
rules={[{ required, message: `${e.desc}(${e.name})为必填字段` }]}
>
{normalArgs(e)}
</Item>
);
})}
{customArgs()}
</div>
);
}
renderInputs(conf: BehaviorNodeTypeModel, isSubtreeNode: boolean) {
if (!conf.input || !conf.input || conf.input.length == 0) {
return null;
}
return (
<div>
<Divider orientation="left">
<h3>输入变量</h3>
</Divider>
{conf.input.map((e, i) => {
return (
<Item label={e} name={`input.${i}`} key={`input.${i}`}>
<Input disabled={isSubtreeNode} onBlur={this.handleSubmit} />
</Item>
);
})}
</div>
);
}
renderOutputs(conf: BehaviorNodeTypeModel, isSubtreeNode: boolean) {
if (!conf.output || !conf.output || conf.output.length == 0) {
return null;
}
return (
<div>
<Divider orientation="left">
<h3>输出变量</h3>
</Divider>
{conf.output.map((e, i) => {
return (
<Item label={e} name={`output.${i}`} key={`output.${i}`}>
<Input disabled={isSubtreeNode} onBlur={this.handleSubmit} />
</Item>
);
})}
</div>
);
}
}
-89
View File
@@ -1,89 +0,0 @@
import React from "react";
import { Button, Card, Form, Input } from "antd";
import { BehaviorTreeModel } from "../../common/BehaviorTreeModel";
import { FormInstance } from "antd/lib/form";
const { Item } = Form;
interface TreePanelProps {
model?: BehaviorTreeModel;
onRenameTree: (name: string) => void;
onRemoveTree: () => void;
onChangeTreeDesc: (desc: string) => void;
}
interface TreePanelState {
}
const inlineFormItemLayout = {
labelCol: {
sm: { span: 4 },
},
wrapperCol: {
sm: { span: 12 },
},
};
export default class TreePanel extends React.Component<TreePanelProps, TreePanelState> {
name: string = '';
desc: string = '';
formRef = React.createRef<FormInstance>();
componentDidMount() {
const { model } = this.props;
this.name = model.name;
this.desc = model.desc;
this.formRef.current.resetFields();
this.formRef.current.setFieldsValue({
name: model.name,
desc: model.desc,
})
}
render() {
const { model } = this.props;
if (!model) {
return <div />;
}
return (
<Card title="概况" style={{ height: "100%" }}>
<Form
{...inlineFormItemLayout}
ref={this.formRef}
>
<Item label="行为树" name="name">
<Input disabled onBlur={this.handleSubmit} />
</Item>
<Item label="说明" name="desc">
<Input onBlur={this.handleSubmit} />
</Item>
{/* <Item>
<Button type='ghost' onClick={this.handleRemoveTree}>删除</Button>
</Item> */}
</Form>
</Card>
);
}
handleSubmit = () => {
const { model, onRenameTree, onChangeTreeDesc } = this.props;
const name = this.formRef.current.getFieldValue("name");
const desc = this.formRef.current.getFieldValue("desc");
if (name != this.name) {
console.log("change name", name);
this.name = name;
model.name = name;
onRenameTree(name);
}
if (desc != this.desc) {
console.log("change desc", desc);
this.desc = desc;
model.desc = desc;
onChangeTreeDesc(desc);
}
}
handleRemoveTree = () => {
this.props.onRemoveTree();
}
}
-690
View File
@@ -1,690 +0,0 @@
import G6, { TreeGraph } from "@antv/g6";
import { G6GraphEvent } from "@antv/g6/lib/interface/behavior";
import { Col, Row, message } from "antd";
import * as fs from "fs";
import * as path from "path";
import * as React from "react";
import {
BehaviorNodeModel,
BehaviorTreeModel,
GraphNodeModel,
} from "../../common/BehaviorTreeModel";
import * as Utils from "../../common/Utils";
import Settings from "../../main-process/Settings";
import NodePanel from "./NodePanel";
import TreePanel from "./TreePanel";
import { Item, Matrix } from "@antv/g6/lib/types";
import { clipboard } from "electron";
import "./Editor.css";
export interface EditorProps {
filepath: string;
onChangeSaveState: (unsave: boolean) => void;
onOpenSubtree: (path: string) => void;
}
interface EditorState {
curNodeId?: string;
blockNodeSelectChange?: boolean;
viewportMatrix?: Matrix;
}
export default class Editor extends React.Component<EditorProps, EditorState> {
private ref: React.RefObject<any>;
state: EditorState = {};
isInEditor: boolean = false;
private graph: TreeGraph;
private dragSrcId: string;
private dragDstId: string;
private autoId: number;
private undoStack: BehaviorNodeModel[] = [];
private redoStack: BehaviorNodeModel[] = [];
private treeModel: BehaviorTreeModel;
private settings: Settings;
private data: GraphNodeModel;
private unsave: boolean = false;
constructor(props: EditorProps) {
super(props);
this.ref = React.createRef();
this.settings = Utils.getRemoteSettings();
const str = fs.readFileSync(this.props.filepath, "utf8");
this.treeModel = JSON.parse(str);
this.data = Utils.createTreeData(this.treeModel.root, this.settings);
this.autoId = Utils.refreshNodeId(this.data);
}
shouldComponentUpdate(nextProps: EditorProps, nextState: EditorState) {
return (
this.props.filepath != nextProps.filepath || this.state.curNodeId != nextState.curNodeId
);
}
setItemState(item: string | Item, state: string, value: string | boolean) {
if (typeof item === "string") {
item = this.graph.findById(item);
}
if (item) {
this.graph.setItemState(item, state, value);
}
}
findParent(node: GraphNodeModel) {
if (node.parent) {
return this.graph.findDataById(node.parent) as GraphNodeModel;
} else {
return null;
}
}
findSubtree(node?: GraphNodeModel): GraphNodeModel | null {
if (node?.path) {
return node;
} else if (node?.parent) {
return this.findSubtree(this.findParent(node));
} else {
return null;
}
}
isSubtreeNode(node?: GraphNodeModel): boolean {
if (node?.path) {
return true;
} else if (node?.parent) {
return this.isSubtreeNode(this.findParent(node));
} else {
return false;
}
}
isAncestor(ancestor: GraphNodeModel, node: GraphNodeModel): boolean {
if (ancestor.id === node.parent) {
return true;
} else if (node.parent) {
return this.isAncestor(
ancestor,
this.graph.findDataById(node.parent) as GraphNodeModel
);
} else {
return false;
}
}
componentDidMount() {
const graph = new TreeGraph({
container: this.ref.current,
width: window.screen.width * 0.66,
height: window.screen.height,
animate: false,
maxZoom: 2,
// fitCenter: true,
modes: {
default: ["drag-canvas", "zoom-canvas", "click-select", "hover"],
},
defaultEdge: {
type: "cubic-horizontal",
style: {
stroke: "#A3B1BF",
},
},
defaultNode: {
type: "TreeNode",
},
layout: {
type: "compactBox",
direction: "LR",
getHGap: () => 50,
getWidth: (d: GraphNodeModel) => {
return 150;
},
getHeight: (d: GraphNodeModel) => {
if (d.size) {
return d.size[1];
} else {
return 50;
}
},
},
});
graph.on("viewportchange", (data: any) => {
if (data.action == "translate" || data.action == "zoom") {
this.state.viewportMatrix = data.matrix;
}
});
graph.on("contextmenu", (e: G6GraphEvent) => {
require("@electron/remote").Menu.getApplicationMenu().popup();
});
graph.on("canvas:mouseover", () => {
this.isInEditor = true;
});
graph.on("canvas:mouseleave", () => {
this.isInEditor = false;
});
graph.on("node:click", (e: G6GraphEvent) => {
if (e.shape.cfg.name === "collapse-icon") {
const item = e.item;
this.onSelectNode(item.getID());
const data = item.getModel();
if ((data as unknown as GraphNodeModel).children?.length > 0) {
data.collapsed = !data.collapsed;
graph.setItemState(item, "collapsed", data.collapsed as boolean);
e.shape.attr("symbol", data.collapsed ? G6.Marker.expand : G6.Marker.collapse);
this.graph.layout();
}
}
});
graph.on("node:dblclick", (e: G6GraphEvent) => {
const data = this.findSubtree(e.item.getModel() as unknown as GraphNodeModel);
if (data) {
this.props.onOpenSubtree(data.path);
}
});
graph.on("node:mouseenter", (e: G6GraphEvent) => {
const { item } = e;
if (item.hasState("selected")) {
return;
}
this.setItemState(item, "hover", true);
});
graph.on("node:mouseleave", (e: G6GraphEvent) => {
const { item } = e;
if (item.hasState("selected")) {
return;
}
this.setItemState(item, "hover", false);
});
graph.on("nodeselectchange", (e: G6GraphEvent) => {
if (this.state.blockNodeSelectChange) {
// ** 重置选中效果
this.onSelectNode(this.state.curNodeId);
return;
}
if (e.target) {
this.onSelectNode(e.target.getID());
} else {
this.onSelectNode(null);
}
});
const clearDragDstState = () => {
if (this.dragDstId) {
this.setItemState(this.dragDstId, "dragRight", false);
this.setItemState(this.dragDstId, "dragDown", false);
this.setItemState(this.dragDstId, "dragUp", false);
this.dragDstId = null;
}
};
const clearDragSrcState = () => {
if (this.dragSrcId) {
this.setItemState(this.dragSrcId, "dragSrc", false);
this.dragSrcId = null;
}
};
graph.on("node:dragstart", (e: G6GraphEvent) => {
this.dragSrcId = e.item.getID();
this.setItemState(this.dragSrcId, "dragSrc", true);
});
graph.on("node:dragend", (e: G6GraphEvent) => {
if (this.dragSrcId) {
this.setItemState(this.dragSrcId, "dragSrc", false);
this.dragSrcId = null;
}
});
graph.on("node:dragover", (e: G6GraphEvent) => {
const dstNodeId = e.item.getID();
if (dstNodeId == this.dragSrcId) {
return;
}
if (this.dragDstId) {
this.setItemState(this.dragDstId, "dragRight", false);
this.setItemState(this.dragDstId, "dragDown", false);
this.setItemState(this.dragDstId, "dragUp", false);
}
const box = e.item.getBBox();
if (e.x > box.minX + box.width * 0.6) {
this.setItemState(dstNodeId, "dragRight", true);
} else if (e.y > box.minY + box.height * 0.5) {
this.setItemState(dstNodeId, "dragDown", true);
} else {
this.setItemState(dstNodeId, "dragUp", true);
}
this.dragDstId = dstNodeId;
});
graph.on("node:dragleave", (e: G6GraphEvent) => {
clearDragDstState();
});
graph.on("node:drop", (e: G6GraphEvent) => {
const srcNodeId = this.dragSrcId;
const dstNode = e.item;
var dragDir;
if (dstNode.hasState("dragRight")) {
dragDir = "dragRight";
} else if (dstNode.hasState("dragDown")) {
dragDir = "dragDown";
} else if (dstNode.hasState("dragUp")) {
dragDir = "dragUp";
}
clearDragSrcState();
clearDragDstState();
if (!srcNodeId) {
console.log("no drag src");
return;
}
if (srcNodeId == dstNode.getID()) {
console.log("drop same node");
return;
}
const rootData = graph.findDataById("1");
const srcData = graph.findDataById(srcNodeId) as GraphNodeModel;
const srcParent = this.findParent(srcData);
const dstData = graph.findDataById(dstNode.getID()) as GraphNodeModel;
const dstParent = this.findParent(dstData);
if (!srcParent) {
console.log("no parent!");
return;
}
if (this.isAncestor(srcData, dstData)) {
// 不能将父节点拖到自已的子孙节点
console.log("cannot move to child");
return;
}
const removeSrc = () => {
this.pushUndoStack();
srcParent.children = srcParent.children.filter((e) => e.id != srcData.id);
};
console.log("dstNode", dstNode);
if (dragDir == "dragRight") {
removeSrc();
if (!dstData.children) {
dstData.children = [];
}
srcData.parent = dstData.id;
dstData.children.push(srcData);
} else if (dragDir == "dragUp") {
if (!dstParent) {
return;
}
removeSrc();
const idx = dstParent.children.findIndex((e) => e.id == dstData.id);
srcData.parent = dstParent.id;
dstParent.children.splice(idx, 0, srcData);
} else if (dragDir == "dragDown") {
if (!dstParent) {
return;
}
removeSrc();
const idx = dstParent.children.findIndex((e) => e.id == dstData.id);
srcData.parent = dstParent.id;
dstParent.children.splice(idx + 1, 0, srcData);
} else {
return;
}
// console.log("cur data", graph.findDataById('1'));
this.changeWithoutAnim();
});
graph.data(this.data);
graph.render();
graph.fitCenter();
graph.set("animate", true);
this.graph = graph;
this.forceUpdate();
}
/**
* remember the last matrix that triggered by Translate or Zoom action , and restore that matrix where the graph is reconstruct.
*/
restoreViewport() {
if (this.state.viewportMatrix) {
this.graph.getGroup().setMatrix(this.state.viewportMatrix);
}
}
onSelectNode(curNodeId: string | null) {
const graph = this.graph;
if (this.state.curNodeId) {
this.setItemState(this.state.curNodeId, "selected", false);
this.setItemState(this.state.curNodeId, "hover", false);
}
this.setState({ curNodeId });
if (this.state.curNodeId) {
this.setItemState(this.state.curNodeId, "selected", true);
}
}
createNode(name: string) {
if (!this.isInEditor) {
return;
}
console.log("editor create node", name);
const { curNodeId } = this.state;
if (!curNodeId) {
message.warn("未选中节点");
return;
}
this.pushUndoStack();
const curNodeData = this.graph.findDataById(curNodeId);
const newNodeData: BehaviorNodeModel = {
id: this.autoId++,
name: name,
};
if (!curNodeData.children) {
curNodeData.children = [];
}
curNodeData.children.push(Utils.createTreeData(newNodeData, this.settings, curNodeId));
this.changeWithoutAnim();
}
deleteNode() {
console.log("editor delete node");
const { curNodeId } = this.state;
if (!curNodeId) {
return;
}
if (curNodeId === "1") {
message.warn("根节点不能删除!");
return;
}
this.onSelectNode(null);
this.pushUndoStack();
const parentData = this.findParent(this.graph.findDataById(curNodeId) as GraphNodeModel);
parentData.children = parentData.children.filter((e) => e.id != curNodeId);
this.changeWithoutAnim();
}
changeWithoutAnim() {
this.graph.set("animate", false);
this.graph.changeData();
this.graph.layout();
this.graph.set("animate", true);
this.props.onChangeSaveState(true);
this.unsave = true;
}
save() {
if (!this.unsave) {
return;
}
const { filepath } = this.props;
const data = this.graph.findDataById("1") as GraphNodeModel;
this.autoId = Utils.refreshNodeId(data);
const root = Utils.createFileData(data);
const treeModel = {
name: path.basename(filepath).slice(0, -5),
root,
desc: this.treeModel.desc,
} as BehaviorTreeModel;
fs.writeFileSync(filepath, JSON.stringify(treeModel, null, 2));
this.props.onChangeSaveState(false);
this.unsave = false;
const treeData = Utils.createTreeData(root, this.settings);
this.autoId = Utils.refreshNodeId(treeData);
this.graph.set("animate", false);
this.graph.changeData(treeData);
this.graph.layout();
this.restoreViewport();
this.graph.set("animate", true);
}
saveAsSubtree(subpath: string) {
const { curNodeId } = this.state;
if (!curNodeId) {
message.warn("未选择一个节点");
return;
} else if (curNodeId == "1") {
message.warn("根节点不能保存为子树");
return;
}
const workdir = this.settings.workdir;
if (subpath.indexOf(workdir) == -1) {
message.warn("请保存在工作区中");
return;
}
const data = this.graph.findDataById(curNodeId) as GraphNodeModel;
const subroot = Utils.createFileData(data);
const subtreeModel = {
name: path.basename(subpath).slice(0, -5),
root: subroot,
desc: data.desc,
} as BehaviorTreeModel;
fs.writeFileSync(subpath, JSON.stringify(subtreeModel, null, 2));
this.pushUndoStack();
data.path = subpath.substring(workdir.length + 1);
this.unsave = true;
this.save();
}
reload() {
if (!this.unsave) {
const data = this.graph.findDataById("1") as GraphNodeModel;
this.autoId = Utils.refreshNodeId(data);
const root = Utils.createFileData(data);
const treeData = Utils.createTreeData(root, this.settings);
this.autoId = Utils.refreshNodeId(treeData);
this.graph.set("animate", false);
this.graph.changeData(treeData);
this.graph.layout();
this.restoreViewport();
this.graph.set("animate", true);
}
}
getTreeModel() {
const { filepath } = this.props;
const data = this.graph.findDataById("1") as GraphNodeModel;
this.autoId = Utils.refreshNodeId(data);
const root = Utils.createFileData(data);
return {
name: path.basename(filepath).slice(0, -5),
root,
desc: this.treeModel.desc,
} as BehaviorTreeModel;
}
copyNode() {
if (!this.isInEditor) {
return;
}
console.log("editor copy node");
const { curNodeId } = this.state;
if (!curNodeId) {
return;
}
const data = this.graph.findDataById(curNodeId) as GraphNodeModel;
if (data) {
const str = JSON.stringify(Utils.cloneNodeData(data), null, 2);
console.log("copy:", str);
clipboard.writeText(str);
}
}
pasteNode() {
if (!this.isInEditor) {
return;
}
const { curNodeId } = this.state;
if (!curNodeId) {
message.warn("未选中节点");
return;
}
const curNodeData = this.graph.findDataById(curNodeId);
try {
const str = clipboard.readText();
if (!str || str == "") {
return;
}
console.log("paste:", str);
const data = Utils.createTreeData(JSON.parse(str), this.settings, curNodeId);
this.autoId = Utils.refreshNodeId(data, this.autoId);
this.onSelectNode(null);
if (!curNodeData.children) {
curNodeData.children = [];
}
this.pushUndoStack();
curNodeData.children.push(data);
// this.autoId = Utils.refreshNodeId(this.graph.findDataById("1") as GraphNodeModel);
this.changeWithoutAnim();
} catch (error) {
// message.error("粘贴数据有误");
console.log(error);
}
}
useStackData(data: BehaviorNodeModel) {
this.graph.set("animate", false);
this.data = Utils.createTreeData(data, this.settings);
this.autoId = Utils.refreshNodeId(this.data);
this.graph.changeData(this.data);
this.graph.layout();
this.restoreViewport();
this.graph.set("animate", true);
this.props.onChangeSaveState(true);
this.unsave = true;
}
pushUndoStack(keepRedo?: boolean) {
this.undoStack.push(Utils.cloneNodeData(this.graph.findDataById("1") as GraphNodeModel));
console.log("push undo", this.undoStack);
if (!keepRedo) {
this.redoStack = [];
}
}
pushRedoStack() {
this.redoStack.push(Utils.cloneNodeData(this.graph.findDataById("1") as GraphNodeModel));
console.log("push redo", this.redoStack);
}
undo() {
if (this.undoStack.length == 0) {
return;
}
const data = this.undoStack.pop();
this.pushRedoStack();
this.useStackData(data);
}
redo() {
if (this.redoStack.length == 0) {
return;
}
const data = this.redoStack.pop();
this.pushUndoStack(true);
this.useStackData(data);
}
changeTreeDesc(desc: string) {
this.treeModel.desc = desc;
this.settings.setTreeDesc(this.props.filepath, desc);
this.unsave = true;
this.save();
}
render() {
const { curNodeId } = this.state;
console.log("render tree", curNodeId);
var curNode: any;
if (curNodeId) {
curNode = this.graph.findDataById(curNodeId);
}
return (
<div className="editor">
<Row className="editorBd">
<Col
span={18}
className="editorContent"
ref={this.ref}
onMouseDownCapture={(event) => {
this.state.blockNodeSelectChange = false;
}}
/>
<Col
span={6}
className="editorSidebar"
onMouseDownCapture={(event) => {
this.state.blockNodeSelectChange = true;
}}
>
{curNode ? (
<NodePanel
model={curNode}
isSubtreeNode={this.isSubtreeNode(curNode)}
settings={this.settings}
updateNode={(id, forceUpdate) => {
if (forceUpdate) {
const data: GraphNodeModel = this.graph.findDataById(
id
) as GraphNodeModel;
data.conf = this.settings.getNodeConf(data.name);
data.size = Utils.calcTreeNodeSize(data);
this.changeWithoutAnim();
this.save();
return;
}
const item = this.graph.findById(id);
item.draw();
this.props.onChangeSaveState(true);
this.unsave = true;
}}
pushUndoStack={() => {
this.pushUndoStack();
}}
/>
) : (
<TreePanel
model={this.treeModel}
onRenameTree={(name: string) => {}}
onRemoveTree={() => {}}
onChangeTreeDesc={(desc) => {
this.changeTreeDesc(desc);
}}
/>
)}
</Col>
</Row>
</div>
);
}
}
-432
View File
@@ -1,432 +0,0 @@
import React, { ChangeEvent, Component } from "react";
import { Input } from "antd";
import { Tree } from "shineout";
import { FolderOutlined, FileOutlined, FolderOpenOutlined } from "@ant-design/icons";
import * as fs from "fs";
import * as path from "path";
import { ipcRenderer } from "electron";
import MainEventType from "../common/MainEventType";
import Settings from "../main-process/Settings";
import * as Utils from "../common/Utils";
const { Search } = Input;
const DirectoryTree = Tree;
class FileDataNode {
name: string; //display name
desc: string; //describe
filepath: string; //file full path
isFolder: boolean;
settings: Settings;
parent: FileDataNode;
visible: boolean = true;
children: FileDataNode[];
public constructor(init?: Partial<FileDataNode>, settings?: Settings) {
Object.assign(this, init);
this.settings = settings;
}
get id() {
return this.filepath;
}
get text() {
if (!this.desc) {
return this.name;
}
return `${this.name}(${this.desc})`;
}
get path() {
return this.filepath;
}
public getRenderData() {
if (!this.visible) {
return null;
}
let ret = new FileDataNode({
name: this.name,
desc: this.isFolder ? undefined : this.settings.getTreeDesc(this.filepath),
filepath: this.filepath,
isFolder: this.isFolder,
children: new Array(),
}, this.settings);
for (const child of this.children) {
const data = child.getRenderData();
if (data) {
ret.children.push(data);
}
}
return ret;
}
loadChilds(recursive?: boolean) {
const folder = this.path;
if (folder == "" || !fs.existsSync(folder)) {
return [];
}
const files = fs.readdirSync(folder).filter((f) => {
return f.endsWith(".json");
});
const list: FileDataNode[] = [];
files.forEach((filename) => {
const fullPath = path.join(folder, filename);
const stat = fs.statSync(fullPath);
const isFolder = stat.isDirectory();
const name = isFolder ? filename : filename.slice(0, -5);
const node = new FileDataNode({
name: name,
desc: isFolder ? undefined : this.settings.getTreeDesc(fullPath),
filepath: fullPath,
isFolder: isFolder,
parent: this,
}, this.settings);
(node.children =
isFolder && recursive ? (node.loadChilds(recursive) as FileDataNode[]) : []),
list.push(node);
});
list.sort((a, b) => {
const av = a.isFolder ? 100 : 0;
const bv = b.isFolder ? 100 : 0;
return bv - av;
});
return list;
}
addChild(filePath: string) {
const isDir = fs.statSync(filePath).isDirectory();
const newNode = new FileDataNode({
name: path.basename(filePath),
filepath: filePath,
isFolder: isDir,
parent: this,
children: [],
}, this.settings);
this.children = [newNode, ...this.children];
}
removeFromParent() {
const parent = this.parent;
if (parent) {
const index = parent.children.indexOf(this);
parent.children.splice(index, 1);
}
}
expandSelf(recursive: boolean = true) {
if (this.isFolder) {
this.children = this.loadChilds(recursive);
}
}
findChild(id: string): FileDataNode {
for (const child of this.children) {
if (child.id == id) {
return child;
} else {
const ret = child.findChild(id);
if (ret) {
return ret;
}
}
}
return null;
}
getList(): FileDataNode[] {
let ret = new Array<FileDataNode>(this);
for (const child of this.children) {
const childList = child.getList();
ret.push(...childList);
}
return ret;
}
setVisible(keyWord: string) {
let ret = false;
for (let i = this.children.length - 1; i >= 0; i--) {
const child = this.children[i];
if (!child.isFolder) {
const keyStr = child.text;
child.visible = keyStr.includes(keyWord);
} else {
child.visible = child.setVisible(keyWord);
}
ret = child.visible || ret;
}
return ret;
}
// name:string;
// path: string;
// isFolder : boolean;
// isEditing?: boolean;
// children?: NodeData[];
}
const NodeActions: { [x: string]: string } = {
["create"]: "New Tree",
["createFolder"]: "New Folder",
["rename"]: "Rename",
["delete"]: "Delete",
["reveal_in_explorer"]: "Reveal In File Explorer",
};
interface ExplorerNodeProps {
visible: boolean;
title: string;
selected: boolean;
expended: boolean;
isLeaf: boolean;
searchKey: string;
}
class ExplorerNode extends Component<ExplorerNodeProps> {
shouldComponentUpdate(nextProps: ExplorerNodeProps) {
return JSON.stringify(this.props) != JSON.stringify(nextProps);
}
render() {
const { title, visible, selected, expended, isLeaf, searchKey } = this.props;
if (!visible) {
return null;
}
const index = title.indexOf(searchKey);
const beforeStr = title.substr(0, index);
const afterStr = title.substr(index + searchKey.length);
return (
<div
className={selected ? "explorer-node-selected" : "explorer-node"}
>
{!isLeaf ? (expended ? <FolderOpenOutlined /> : <FolderOutlined />) : <FileOutlined />}
{isLeaf && index > -1 ? (
<span className="explorer-node-title">
{beforeStr}
<span className="explorer-node-search-value">{searchKey}</span>
{afterStr}
</span>
) : (
<span className="explorer-node-title">{title}</span>
)}
</div>
);
}
}
export interface ExplorerProps {
workdir: string;
onOpenTree: (path: string) => void;
onDeleteTree: (path: string) => void;
}
interface ExplorerState {
root: FileDataNode;
searchKey: string;
selectedKey: string;
defaultExpandedKeys: string[];
expandedKeys: string[];
autoExpandParent: boolean;
rightClickNode: {
pageX: number;
pageY: number;
key: string;
};
}
export default class Explorer extends Component<ExplorerProps> {
state: ExplorerState = {
root: this.getRootNode(this.props.workdir),
searchKey: "",
selectedKey: "",
rightClickNode: null,
expandedKeys: [],
defaultExpandedKeys: [],
autoExpandParent: true,
};
curWorkdir: string = "";
settings: Settings;
shouldComponentUpdate(nextProps: ExplorerProps) {
const shouldUpdate = this.curWorkdir != nextProps.workdir;
this.curWorkdir = nextProps.workdir;
return shouldUpdate;
}
componentDidMount() {
this.settings = Utils.getRemoteSettings();
ipcRenderer.on(MainEventType.CREATE_TREE, (event: any, path: string) => {
console.log("on Create tree", path);
this.props.onOpenTree(path);
this.state.root.expandSelf();
this.forceUpdate();
});
ipcRenderer.on(MainEventType.OPEN_DIR, (event: any, workdir: any, workspace: string) => {
console.log("prop on open workspace", workspace);
this.updateRoot();
this.forceUpdate();
});
this.updateRoot();
}
updateRoot() {
var workdir = this.props.workdir;
if (!workdir || workdir === "") {
return;
}
const root = this.getRootNode(workdir);
root.expandSelf()
this.setState({
root: root,
expandedKeys: [root.id],
defaultExpandedKeys: [root.id],
});
this.forceUpdate();
}
getRootNode(workdir: string) {
if (workdir && workdir !== "") {
return new FileDataNode({
name: path.basename(workdir),
filepath: workdir,
isFolder: true,
parent: null,
children: []
}, this.settings);
} else {
return null;
}
}
// renderContextMeun(selectedNode: FileDataNode) {
// const actions = Object.keys(NodeActions);
// return (
// <Menu>
// {actions.map((action) => {
// const displayName: string = NodeActions[action];
// return (
// <Menu.Item
// key={action}
// onClick={(info) => {
// //this.handleContextMenu({ node: selectedNode, action:{action} });
// }}
// >{displayName}
// </Menu.Item>
// );
// })}
// </Menu>
// );
// }
handleOnSearch(value: string) {
const expandedKeys = this.state.root
.getList()
.map((item) => {
if (item.isFolder) return null;
const title = item.text;
if (title.includes(value) && item.parent) {
return item.parent.id;
}
return null;
})
.filter((item, i, self) => item && self.indexOf(item) === i);
const root = this.state.root;
root.setVisible(value || "");
if (value && value.length > 0) {
this.setState({
root: root,
searchKey: value,
expandedKeys: expandedKeys,
autoExpandParent: true,
});
} else {
this.setState({
root: root,
searchKey: "",
expandedKeys: this.state.defaultExpandedKeys,
autoExpandParent: false,
});
}
this.forceUpdate();
}
selectNode(id: string) {
this.setState({
selectedKey: id
});
this.forceUpdate();
}
renderItem(node: FileDataNode) {
return (<ExplorerNode
visible={node.visible}
title={node.text}
selected={this.state.selectedKey == node.id}
expended={this.state.expandedKeys.includes(node.id)}
isLeaf={!node.isFolder}
searchKey={this.state.searchKey}
/>);
}
render() {
console.log("render Explorer");
const { onOpenTree, onDeleteTree, workdir } = this.props;
const root = this.state.root;
if (!workdir || workdir === "" || !root) {
return `请打开workspace.json文件`;
}
const nodes = [root.getRenderData()];
return (
<div>
<Search
allowClear
placeholder="Search"
// onChange={(e) => {
// const value = e.target.value?.toLowerCase();
// this.handleOnSearch(value);
// }}
onSearch={(value, event) => {
this.handleOnSearch(value?.toLowerCase());
}}
/>
<DirectoryTree
keygen="id"
data={nodes}
line={false}
doubleClickExpand
defaultExpanded={[root.id]}
expanded={this.state.expandedKeys}
value={[this.state.selectedKey]}
onExpand={(expanded) => {
this.setState({ expandedKeys: expanded })
this.forceUpdate();
}}
renderItem={this.renderItem.bind(this)}
onClick={(node: FileDataNode) => {
if (!node.isFolder) {
onOpenTree(node.path);
}
}}
></DirectoryTree>
</div>
);
}
}
-355
View File
@@ -1,355 +0,0 @@
import G6 from "@antv/g6";
import { toBreakWord } from "../common/Utils";
import Settings from "../main-process/Settings";
import { BehaviorNodeTypeModel } from "../common/BehaviorTreeModel";
const NODE_COLORS: any = {
["Composite"]: "rgb(91,237,32)",
["Decorator"]: "rgb(218,167,16)",
["Condition"]: "rgb(228,20,139)",
["Action"]: "rgb(91,143,249)",
["Other"]: "rgb(112,112,112)",
["Error"]: "rgb(255,0,0)",
};
const SELECTED_LINE_WIDTH = 4;
export default function (settings: Settings) {
const collapseStyle: any = {
"collapse-icon": {
// symbol: G6.Marker.expand,
fill: "blue",
},
};
G6.registerNode(
"TreeNode",
{
options: {
type: "rect",
anchorPoints: [
[0, 0.5],
[1, 0.5],
],
labelCfg: {
style: {
fill: "blue",
fontSize: 10,
},
},
style: {
fill: "white",
},
stateStyles: {
selected: {
"main-box": {
stroke: "yellow",
lineWidth: SELECTED_LINE_WIDTH,
},
"name-bg-selected": {
fill: "yellow",
},
},
hover: {
stroke: "white",
lineWidth: 3,
},
dragSrc: {
fill: "gray",
},
dragRight: {
"drag-right": {
fillOpacity: 0.7,
},
},
dragUp: {
"drag-up": {
fillOpacity: 0.7,
},
},
dragDown: {
"drag-down": {
fillOpacity: 0.7,
},
},
},
},
draw(cfg, group) {
const nodeConf = settings.getNodeConf(cfg.name as string);
let classify = nodeConf.type || "Other";
if (
!settings.hasNodeConf(cfg.name as string) ||
(cfg.path && (!cfg.children || (cfg.children as []).length == 0))
) {
classify = "Error";
}
const color = NODE_COLORS[classify] || "Other";
var size = cfg.size ? (cfg.size as number[]) : [150, 40];
const w = size[0];
const h = size[1];
const r = 4;
const shape = group.addShape("rect", {
attrs: {
x: 0,
y: 0,
width: w,
height: h,
stroke: color,
lineWidth: 2,
fill: "white",
radius: r,
},
name: "main-box",
draggable: true,
});
// is subtree
if (cfg.path && cfg.id !== "1") {
group.addShape("rect", {
attrs: {
x: -10,
y: -10,
width: w + 20,
height: h + 20,
stroke: "white",
lineWidth: 2.5,
lineDash: [5, 5],
radius: [r, r, r, r],
},
name: "subtree",
draggable: true,
});
}
// name bg
group.addShape("rect", {
attrs: {
x: 0,
y: 0,
width: w,
height: 20,
fill: color,
radius: [r, r, 0, 0],
},
name: "name-bg-selected",
draggable: true,
});
group.addShape("rect", {
attrs: {
x: SELECTED_LINE_WIDTH / 2,
y: SELECTED_LINE_WIDTH / 2,
width: w - 5,
height: 20 - SELECTED_LINE_WIDTH / 2,
fill: color,
radius: [r, r, 0, 0],
},
name: "name-bg",
draggable: true,
});
// id text
group.addShape("text", {
attrs: {
textBaseline: "top",
x: -3,
y: h / 2 - 8,
fontSize: 20,
lineHeight: 20,
text: cfg.id,
textAlign: "right",
fill: "white",
stroke: "black",
lineWidth: 2,
},
name: "id-text",
});
// icon
var img = `./static/icons/${classify}.svg`;
group.addShape("image", {
attrs: {
x: 5,
y: 2,
height: 14,
width: 14,
img,
},
name: "node-icon",
});
// debug
if (cfg.debug) {
group.addShape("image", {
attrs: {
x: 182,
y: 2,
height: 14,
width: 14,
img: `./static/icons/Debug.svg`,
},
name: "node-icon",
});
}
// name text
group.addShape("text", {
attrs: {
textBaseline: "top",
x: 22,
y: 3,
fontWeight: 800,
lineHeight: 20,
text: cfg.name,
fill: "black",
},
name: "name-text",
});
var x = 5;
var y = 24;
// desc text
const desc = cfg.desc || nodeConf.desc;
if (desc) {
group.addShape("text", {
attrs: {
textBaseline: "top",
x,
y,
lineHeight: 20,
fontWeight: 800,
text: `备注:${desc}`,
fill: "black",
},
name: "desc-text",
});
}
const args: any = cfg.args;
if (nodeConf.args && args && Object.keys(args).length > 0) {
const { str, line } = toBreakWord(`参数:${JSON.stringify(args)}`, 34);
group.addShape("text", {
attrs: {
textBaseline: "top",
x,
y: y + 20,
w,
lineHeight: 20,
text: str,
fill: "black",
},
name: "args-text",
});
y += 20 * line;
}
const input: [] = cfg.input ? (cfg.input as []) : [];
if (nodeConf.input && input.length > 0) {
const { str, line } = toBreakWord(`输入:${JSON.stringify(input)}`, 35);
group.addShape("text", {
attrs: {
textBaseline: "top",
x,
y: y + 20,
lineHeight: 20,
text: str,
fill: "black",
},
name: "input-text",
});
y += 20 * line;
}
const output: [] = cfg.output ? (cfg.output as []) : [];
if (nodeConf.output && output.length > 0) {
const { str, line } = toBreakWord(`输出:${JSON.stringify(output)}`, 35);
group.addShape("text", {
attrs: {
textBaseline: "top",
x,
y: y + 20,
lineHeight: 20,
text: str,
fill: "black",
},
name: "output-text",
});
y += 20 * line;
}
if (cfg.path) {
group.addShape("text", {
attrs: {
textBaseline: "top",
x,
y: y + 20,
lineHeight: 20,
text: `子树:${cfg.path}`,
fill: "black",
},
name: "subtree-text",
});
y += 20;
}
group.addShape("rect", {
name: "drag-up",
attrs: {
x: 0,
y: 0,
width: w,
height: h / 2,
fill: "blue",
fillOpacity: 0,
radius: [r, r, 0, 0],
},
draggable: true,
});
group.addShape("rect", {
name: "drag-down",
attrs: {
x: 0,
y: h / 2,
width: w,
height: h / 2,
fill: "blue",
fillOpacity: 0,
radius: [0, 0, r, r],
},
draggable: true,
});
group.addShape("rect", {
name: "drag-right",
attrs: {
x: w / 2,
y: 0,
width: w / 2,
height: h,
fill: "blue",
fillOpacity: 0,
radius: [0, r, r, 0],
},
draggable: true,
});
if (Array.isArray(cfg.children) && cfg.children.length > 0) {
group.addShape("marker", {
attrs: {
x: w,
y: h / 2,
r: 6,
symbol: G6.Marker.collapse,
stroke: "#666",
lineWidth: 1,
fill: "#fff",
},
name: "collapse-icon",
});
}
return shape;
},
},
"single-node"
);
}
-218
View File
@@ -1,218 +0,0 @@
import { Tabs, message } from "antd";
import { ipcRenderer } from "electron";
import * as fs from "fs";
import * as path from "path";
import React, { Component } from "react";
import * as Utils from "../common/Utils";
import Editor from "./Editor";
import "antd/dist/antd.dark.css";
import { BehaviorTreeModel } from "../common/BehaviorTreeModel";
import MainEventType from "../common/MainEventType";
const { TabPane } = Tabs;
interface TreeTabsProps {
onTabSelected: (path: string) => void;
}
interface TreeInfo {
filepath: string;
unsave?: boolean;
}
interface TreeTabsState {
trees: TreeInfo[];
curTree?: string;
}
export default class TreeTabs extends Component<TreeTabsProps, TreeTabsState> {
state: TreeTabsState = {
trees: [],
};
editors: { [path: string]: Editor } = {};
componentDidMount() {
ipcRenderer.on(MainEventType.CREATE_NODE, (event: any, name: any) => {
const editor = this.getCurEditor();
editor?.createNode(name);
});
ipcRenderer.on(MainEventType.DELETE_NODE, () => {
const editor = this.getCurEditor();
editor?.deleteNode();
});
ipcRenderer.on(MainEventType.COPY_NODE, () => {
const editor = this.getCurEditor();
editor?.copyNode();
});
ipcRenderer.on(MainEventType.PASTE_NODE, () => {
const editor = this.getCurEditor();
editor?.pasteNode();
});
ipcRenderer.on(MainEventType.SAVE, (event: any) => {
const { curTree: curPath } = this.state;
if (!curPath) {
return;
}
const editor = this.editors[curPath];
editor.save();
message.success("已保存");
});
ipcRenderer.on(MainEventType.SAVE_AS_SUBTREE, (event: any, path: string) => {
const { curTree: curPath } = this.state;
if (!curPath) {
return;
}
const editor = this.editors[curPath];
editor.saveAsSubtree(path);
});
ipcRenderer.on(MainEventType.SAVE_ALL, (event: any) => {
for (let k in this.editors) {
let editor = this.editors[k];
editor.save();
}
message.success("已保存所有行为树");
});
ipcRenderer.on(MainEventType.BUILD, async (event, buildDir: string) => {
for (let k in this.editors) {
let editor = this.editors[k];
editor.save();
}
const settings = Utils.getRemoteSettings();
const files = fs.readdirSync(settings.workdir).filter((f) => {
return f.endsWith(".json");
});
for (const path of files) {
const buildpath = buildDir + "/" + path;
console.log("build:", buildpath);
const treeModel = Utils.createBuildData(path, settings);
fs.writeFileSync(buildpath, JSON.stringify(treeModel, null, 2));
}
message.success("已构建所有行为树");
});
ipcRenderer.on(MainEventType.UNDO, () => {
const editor = this.getCurEditor();
if (editor?.isInEditor) {
editor.undo();
}
});
ipcRenderer.on(MainEventType.REDO, () => {
const editor = this.getCurEditor();
if (editor?.isInEditor) {
editor.redo();
}
});
}
componentDidUpdate() {
setTimeout(() => {
this.props.onTabSelected(this.state.curTree);
}, 500);
}
getOpenTreesModel() {
const trees: BehaviorTreeModel[] = [];
for (let k in this.editors) {
let editor = this.editors[k];
if (editor) {
editor.save();
trees.push(editor.getTreeModel());
}
}
return trees;
}
getCurEditor() {
const { curTree: curPath } = this.state;
if (!curPath) {
return;
}
return this.editors[curPath];
}
openFile(path: string) {
if (!this.state.trees.find((e) => e.filepath == path)) {
const trees = this.state.trees;
trees.push({ filepath: path });
this.setState({ trees, curTree: path });
} else {
this.setState({ curTree: path });
}
}
closeFile(path: string) {
let trees = this.state.trees;
trees = trees.filter((tree) => tree.filepath != path);
const length = trees.length;
this.setState({ trees: trees, curTree: length > 0 ? trees[0].filepath : null });
if (length > 0) {
const editor = this.editors[trees[0].filepath];
editor?.reload();
}
}
render() {
const { trees, curTree } = this.state;
console.log("render tabs", trees);
if (!curTree) {
return <div />;
}
this.editors = {};
return (
<Tabs
hideAdd
className="tabs"
type="editable-card"
defaultActiveKey={curTree}
activeKey={curTree}
onChange={(activeKey) => {
const editor = this.editors[activeKey];
editor?.reload();
this.setState({ curTree: activeKey });
}}
onEdit={(targetKey, action) => {
if (action == "remove") {
this.closeFile(targetKey as string);
}
}}
>
{trees.map((tree) => {
return (
<TabPane
tab={`${path.basename(tree.filepath)}${tree.unsave ? "*" : ""}`}
key={tree.filepath}
>
<Editor
filepath={tree.filepath}
onChangeSaveState={(unsave) => {
if (tree.unsave != unsave) {
tree.unsave = unsave;
this.forceUpdate();
}
}}
onOpenSubtree={(path) => {
const settings = Utils.getRemoteSettings();
this.openFile(settings.workdir + "/" + path);
}}
ref={(ref) => {
this.editors[tree.filepath] = ref;
}}
/>
</TabPane>
);
})}
</Tabs>
);
}
}
-97
View File
@@ -1,97 +0,0 @@
body {
/* background-color: #242424; */
background-color: rgb(20, 20, 20);
padding : 0px;
}
.body {
width : 100%;
height : 100%;
position: fixed;
}
.ant-layout {
flex-wrap: wrap;
}
.sider {
height : 100%;
overflow-y : auto;
overflow-x : hidden;
transition : all 0s;
}
.sider:hover {
overflow-y : auto;
}
#sizer-hint-bar {
position : absolute;
width : 10px;
height : 100vh;
background : #66ccff;
top : 0;
left : 245px;
opacity : 0;
transition : opacity 0.3s;
cursor : col-resize;
z-index : 10;
}
.content {
width : 100%;
height : 100%;
display: flex;
/* background-color: #242424; */
background-color: rgb(20, 20, 20);
}
.tabs {
width: 100%;
padding: 0;
}
::-webkit-scrollbar {
background-color: rgb(20, 20, 20);
}
::-webkit-scrollbar-thumb {
height : 50px;
background-color: #818181;
}
[data-theme="dark"] .site-tree-search-value {
color: #d84a1b;
}
.explorer-node {
margin: 0;
padding: 0;
line-height: 20px;
white-space: nowrap;
list-style: none;
outline: 0;
color: #ffffff;
}
.explorer-node-selected {
margin: 0;
padding: 0;
line-height: 20px;
white-space: nowrap;
list-style: none;
outline: 0;
color: #ffffff;
background-color: #818181;
}
.explorer-node-title {
color: #ffffff;
}
.explorer-node-search-value {
color: #f50;
}
-196
View File
@@ -1,196 +0,0 @@
import * as ReactDOM from "react-dom";
import SyncRequest from "sync-request";
import React, { Component } from "react";
import { Layout, message, Tabs } from "antd";
import * as Utils from "../common/Utils";
import MainEventType from "../common/MainEventType";
import Settings from "../main-process/Settings";
import { ipcRenderer } from "electron";
import "antd/dist/antd.dark.css";
import "./index.css";
import RegisterNode from "./RegisterNode";
import TreeTabs from "./TreeTabs";
import Explorer from "./Explorer";
import BatchExec from "../common/BatchExec";
const { Sider, Content } = Layout;
interface MainState {
workdir: string;
workspace: string;
}
export default class Main extends Component {
state: MainState = {
workdir: "",
workspace: "",
};
settings: Settings;
tabs: TreeTabs;
explorer: Explorer;
session = 0;
componentDidMount() {
this.updateSettings();
RegisterNode(this.settings);
ipcRenderer.on(MainEventType.OPEN_FILE, (event: any, path: any) => {
this.setState({ curPath: path });
});
ipcRenderer.on(MainEventType.OPEN_DIR, (event: any, workdir: any, workspace: string) => {
console.log("on open workspace", workspace);
document.title = workspace;
this.setState({ workdir, workspace });
});
ipcRenderer.on(MainEventType.RELOAD_SERVER, () => {
const serverModel = this.settings.serverModel;
if (!serverModel) {
message.warning("没有配置服务器");
return;
}
SyncRequest("POST", serverModel.host, {
body: JSON.stringify({
cmd: "btree.reload",
data: {
trees: this.tabs.getOpenTreesModel(),
},
session: this.session++,
timestamp: Date.now(),
}),
});
message.success("服务器已更新");
});
ipcRenderer.on(MainEventType.BATCH_EXEC, (event: any, path: string) => {
BatchExec(path, this.state.workdir);
});
console.log("workdir", this.settings.curWorkspace.getWorkdir());
setTimeout(() => {
this.setState({
workdir: this.settings.curWorkspace.getWorkdir(),
workspace: this.settings.curWorkspace.getFilepath(),
});
}, 50);
this.setReSizer();
}
setReSizer() {
let startX: number;
let startWidth: number;
const sider: any = document.getElementsByClassName("sider")[0];
const reSizerParent: any = document.getElementsByClassName("ant-layout-sider-children")[0];
const reSizer: any = document.getElementById("sizer-hint-bar");
const content: any = document.getElementsByClassName("ant-layout-content content")[0];
function initDrag(e: DragEvent) {
startX = e.clientX;
startWidth = parseInt(document.defaultView.getComputedStyle(reSizerParent).width, 10);
document.documentElement.addEventListener("mousemove", doDrag, false);
document.documentElement.addEventListener("mouseup", stopDrag, false);
}
function doDrag(e: DragEvent) {
const calcWidth = startWidth + e.clientX - startX;
setWidth(calcWidth);
}
function setWidth(width: number) {
const maxValue = window.innerWidth * 0.8;
const minValue = 100;
if (width > maxValue) {
width = maxValue;
} else if (width < minValue) {
width = minValue;
}
reSizerParent.style.width = width + "px";
sider.style.width = width + "px";
sider.style.maxWidth = width + "px";
sider.style.minWidth = "0px";
sider.style.flex = "";
reSizer.style.left = width - 5 + "px";
content.style.width = window.innerWidth - width + "px";
}
function stopDrag(e: DragEvent) {
document.documentElement.removeEventListener("mousemove", doDrag, false);
document.documentElement.removeEventListener("mouseup", stopDrag, false);
}
reSizer.addEventListener(
"mouseover",
function () {
reSizer.style.opacity = "1";
reSizer.addEventListener("mousedown", initDrag, false);
reSizer.addEventListener(
"mouseout",
function init() {
reSizer.style.opacity = "0";
},
false
);
},
false
);
window.addEventListener("resize", function () {
const currentWidth = parseInt(
document.defaultView.getComputedStyle(reSizerParent).width,
10
);
setWidth(currentWidth);
});
setWidth(250);
}
updateSettings() {
this.settings = Utils.getRemoteSettings();
}
render() {
console.log("render main");
const { workdir, workspace } = this.state;
document.title = `行为树编辑器 - ${workspace}`;
return (
<Layout className="body" style={{ flexDirection: "column" }}>
<Sider className="sider" width={250}>
{workdir !== "" ? (
<Explorer
ref={(ref) => {
this.explorer = ref;
}}
workdir={workdir}
onOpenTree={(path) => {
this.tabs.openFile(path);
}}
onDeleteTree={(path) => {
this.tabs.closeFile(path);
}}
/>
) : (
"Please Open Workspace"
)}
</Sider>
<div id="sizer-hint-bar" />
<Content className="content">
<TreeTabs
ref={(ref) => {
this.tabs = ref;
}}
onTabSelected={(path) => {
this.explorer.selectNode(path);
}}
/>
</Content>
</Layout>
);
}
}
ReactDOM.render(<Main />, document.getElementById("root") as HTMLElement);
+236
View File
@@ -0,0 +1,236 @@
[
{
"name": "AlwaysFail",
"type": "Decorator",
"doc": "+ 只能有一个子节点,多个仅执行第一个\n+ 不管子节点是否成功都返回失败\n",
"desc": "始终返回失败"
},
{
"name": "AlwaysSuccess",
"type": "Decorator",
"doc": "+ 只能有一个子节点,多个仅执行第一个\n+ 不管子节点是否成功都返回成功\n",
"desc": "始终返回成功"
},
{
"name": "Attack",
"type": "Action",
"input": [
"{目标}"
],
"desc": "攻击"
},
{
"name": "Cmp",
"type": "Condition",
"doc": "+ 若值为空,返回失败\n+ 非整数类型可能会报错\n",
"input": [
"值(int)"
],
"args": [
{
"name": "value",
"desc": "值",
"type": "code?"
},
{
"name": "gt",
"desc": ">",
"type": "int?"
},
{
"name": "ge",
"desc": ">=",
"type": "int?"
},
{
"name": "eq",
"desc": "==",
"type": "int?"
},
{
"name": "le",
"desc": "<=",
"type": "int?"
},
{
"name": "lt",
"desc": "<",
"type": "int?"
}
],
"desc": "比较值大小"
},
{
"name": "FindEnemy",
"output": [
"目标单位"
],
"type": "Condition",
"doc": "+ 没找到返回失败\n",
"args": [
{
"name": "x",
"desc": "x",
"type": "int?"
},
{
"name": "y",
"desc": "y",
"type": "int?"
},
{
"name": "w",
"desc": "宽",
"type": "int?"
},
{
"name": "h",
"desc": "高",
"type": "int?"
},
{
"name": "count",
"desc": "查找上限",
"type": "string?"
}
],
"desc": "查找敌人"
},
{
"name": "GetHp",
"output": [
"生命值"
],
"type": "Action",
"desc": "获取生命值"
},
{
"name": "Idle",
"type": "Action",
"desc": "待机"
},
{
"name": "Log",
"type": "Action",
"args": [
{
"name": "str",
"desc": "日志",
"type": "string"
}
],
"desc": "打印日志"
},
{
"name": "MoveToPos",
"type": "Action",
"args": [
{
"name": "x",
"desc": "x",
"type": "int"
},
{
"name": "y",
"desc": "y",
"type": "int"
}
],
"desc": "移动到坐标"
},
{
"name": "MoveToTarget",
"type": "Action",
"input": [
"{目标}"
],
"desc": "移动到目标"
},
{
"name": "Not",
"type": "Decorator",
"doc": "+ 将子节点的返回值取反\n",
"desc": "取反"
},
{
"name": "Parallel",
"type": "Composite",
"doc": "执行所有子节点并返回成功\n",
"desc": "并行执行"
},
{
"name": "Selector",
"type": "Composite",
"doc": "+ 一直往下执行,有子节点返回成功则返回成功,若全部节点返回失败则返回失败\n+ 子节点是或的关系\n",
"desc": "选择执行"
},
{
"name": "Sequence",
"type": "Composite",
"doc": "+ 一直往下执行,有子节点返回成功则返回成功,若全部节点返回失败则返回失败\n+ 子节点是或的关系\n",
"desc": "顺序执行"
},
{
"name": "Wait",
"type": "Action",
"args": [
{
"name": "time",
"desc": "时间/tick",
"type": "int"
}
],
"desc": "等待"
},
{
"name": "TestB3",
"type": "Action",
"args": [
{
"name": "time",
"desc": "时间",
"type": "int"
},
{
"name": "random",
"desc": "随机",
"type": "float?"
},
{
"name": "open",
"desc": "打开来",
"type": "boolean"
},
{
"name": "check",
"desc": "检查",
"type": "code"
},
{
"name": "name",
"desc": "检查",
"type": "string"
},
{
"name": "status",
"desc": "状态",
"type": "enum",
"options": [
{
"name": "成功",
"value": "SUCCESS"
},
{
"name": "失败",
"value": "FAILURE"
},
{
"name": "运行中",
"value": "RUNNING"
}
]
}
],
"desc": "等待"
}
]
-1
View File
@@ -1 +0,0 @@
[{"name":"AlwaysFail","type":"Decorator","doc":"+ 只能有一个子节点,多个仅执行第一个\n+ 不管子节点是否成功都返回失败\n","desc":"始终返回失败"},{"name":"AlwaysSuccess","type":"Decorator","doc":"+ 只能有一个子节点,多个仅执行第一个\n+ 不管子节点是否成功都返回成功\n","desc":"始终返回成功"},{"name":"Attack","type":"Action","input":["{目标}"],"desc":"攻击"},{"name":"Cmp","type":"Condition","doc":"+ 若值为空,返回失败\n+ 非整数类型可能会报错\n","input":["值(int)"],"args":[{"name":"value","desc":"值","type":"code?"},{"name":"gt","desc":">","type":"int?"},{"name":"ge","desc":">=","type":"int?"},{"name":"eq","desc":"==","type":"int?"},{"name":"le","desc":"<=","type":"int?"},{"name":"lt","desc":"<","type":"int?"}],"desc":"比较值大小"},{"name":"FindEnemy","output":["目标单位"],"type":"Condition","doc":"+ 没找到返回失败\n","args":[{"name":"x","desc":"x","type":"int?"},{"name":"y","desc":"y","type":"int?"},{"name":"w","desc":"宽","type":"int?"},{"name":"h","desc":"高","type":"int?"},{"name":"count","desc":"查找上限","type":"string?"}],"desc":"查找敌人"},{"name":"GetHp","output":["生命值"],"type":"Action","desc":"获取生命值"},{"name":"Idle","type":"Action","desc":"待机"},{"name":"Log","type":"Action","args":[{"name":"str","desc":"日志","type":"string"}],"desc":"打印日志"},{"name":"MoveToPos","type":"Action","args":[{"name":"x","desc":"x","type":"int"},{"name":"y","desc":"y","type":"int"}],"desc":"移动到坐标"},{"name":"MoveToTarget","type":"Action","input":["{目标}"],"desc":"移动到目标"},{"name":"Not","type":"Decorator","doc":"+ 将子节点的返回值取反\n","desc":"取反"},{"name":"Parallel","type":"Composite","doc":"执行所有子节点并返回成功\n","desc":"并行执行"},{"name":"Selector","type":"Composite","doc":"+ 一直往下执行,有子节点返回成功则返回成功,若全部节点返回失败则返回失败\n+ 子节点是或的关系\n","desc":"选择执行"},{"name":"Sequence","type":"Composite","doc":"+ 一直往下执行,有子节点返回成功则返回成功,若全部节点返回失败则返回失败\n+ 子节点是或的关系\n","desc":"顺序执行"},{"name":"Wait","type":"Action","args":[{"name":"time","desc":"时间/tick","type":"int"}],"desc":"等待"}]
+8 -10
View File
@@ -24,13 +24,12 @@
"id": 4,
"name": "Sequence",
"desc": "新建行为树",
"args": {},
"path": "subtree2.json"
"path": "workdir/subtree2.json"
}
]
},
{
"id": 6,
"id": 7,
"name": "Cmp",
"args": {
"gt": 50,
@@ -41,24 +40,23 @@
]
},
{
"id": 7,
"name": "Log",
"id": 8,
"name": "TestB3",
"desc": "攻击",
"args": {
"str": "Attack!"
},
"children": []
}
},
{
"id": 8,
"id": 9,
"name": "Sequence",
"desc": "新建行为树",
"path": "subtree1.json"
"path": "workdir/subtree1.json"
}
]
},
{
"id": 11,
"id": 12,
"name": "Log",
"desc": "逃跑",
"args": {
+31
View File
@@ -0,0 +1,31 @@
{
"name": "subtree1",
"root": {
"id": 1,
"name": "Sequence",
"desc": "新建行为树",
"children": [
{
"id": 2,
"name": "Log",
"args": {
"str": "hello"
}
},
{
"id": 3,
"name": "Wait",
"args": {
"time": 1
},
"children": [
{
"id": 4,
"name": "unknow"
}
]
}
]
},
"desc": "子树1"
}
+26
View File
@@ -0,0 +1,26 @@
{
"name": "subtree2",
"root": {
"id": 1,
"name": "Sequence",
"desc": "新建行为树",
"children": [
{
"id": 2,
"name": "Cmp",
"args": {},
"input": [
""
]
},
{
"id": 3,
"name": "Attack",
"input": [
"in"
]
}
]
},
"desc": "子树2"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "subtree",
"name": "subtree1",
"root": {
"id": 1,
"name": "Sequence",
File renamed without changes.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="216" height="216" viewBox="0 0 216 216"><path fill="#80bd01" d="M104.6 180.7c-2 0-3.9-.5-5.7-1.5l-18.1-10.7c-2.7-1.5-1.4-2-.5-2.4 3.6-1.2 4.3-1.5 8.2-3.7.4-.2.9-.1 1.3.1l13.9 8.2c.5.3 1.2.3 1.7 0l54.1-31.2c.5-.3.8-.9.8-1.5V75.7c0-.6-.3-1.2-.8-1.5l-54-31.2c-.5-.3-1.2-.3-1.7 0l-54 31.2c-.5.3-.9.9-.9 1.5v62.4c0 .6.3 1.2.9 1.4l14.8 8.6c8 4 13-.7 13-5.5V81c0-.9.7-1.6 1.6-1.6h6.9c.9 0 1.6.7 1.6 1.6v61.6c0 10.7-5.8 16.9-16 16.9-3.1 0-5.6 0-12.5-3.4L44.8 148c-3.5-2-5.7-5.8-5.7-9.9V75.7c0-4.1 2.2-7.8 5.7-9.9l54.1-31.2c3.4-1.9 8-1.9 11.4 0l54.1 31.2c3.5 2 5.7 5.8 5.7 9.9v62.4c0 4.1-2.2 7.8-5.7 9.9l-54.1 31.2c-1.8 1-3.7 1.5-5.7 1.5zm43.6-61.5c0-11.7-7.9-14.8-24.5-17-16.8-2.2-18.5-3.4-18.5-7.3 0-3.2 1.4-7.6 13.9-7.6 11.1 0 15.2 2.4 16.9 9.9.1.7.8 1.2 1.5 1.2h7c.4 0 .8-.2 1.1-.5.3-.3.5-.8.4-1.2-1.1-12.9-9.7-18.9-27-18.9-15.4 0-24.6 6.5-24.6 17.4 0 11.8 9.1 15.1 23.9 16.6 17.7 1.7 19.1 4.3 19.1 7.8 0 6-4.8 8.6-16.2 8.6-14.3 0-17.5-3.6-18.5-10.7-.1-.8-.8-1.3-1.6-1.3h-7c-.9 0-1.6.7-1.6 1.6 0 9.1 5 20 28.6 20 17.3-.1 27.1-6.8 27.1-18.6zM172 55.9V57h3v8h1.2v-8h3.1v-1.1H172zm8.4 9.1h1.2v-7.6l2.6 7.6h1.2l2.6-7.6V65h1.2v-9h-1.7l-2.6 7.6-2.6-7.6h-1.8v9z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+936
View File
@@ -0,0 +1,936 @@
import {
EditEvent,
EditNode,
EditTree,
EditorStore,
useWorkspace,
} from "@/contexts/workspace-context";
import { NodeModel, TreeGraphData, TreeModel } from "@/misc/b3type";
import * as b3util from "@/misc/b3util";
import { message } from "@/misc/hooks";
import { Hotkey, isHotkeyPressed, useHotkeys } from "@/misc/keys";
import Path from "@/misc/path";
import G6, { G6GraphEvent, Item, TreeGraph } from "@antv/g6";
import { dialog } from "@electron/remote";
import { useSize } from "ahooks";
import { Dropdown, Flex, FlexProps, MenuProps } from "antd";
import { clipboard } from "electron";
import * as fs from "fs";
import React, { FC, useCallback, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { FiDelete } from "react-icons/fi";
import { IoMdReturnLeft } from "react-icons/io";
import { mergeRefs } from "react-merge-refs";
import "./register-node";
export interface EditorProps extends React.HTMLAttributes<HTMLElement> {
data: EditorStore;
onUpdate: () => void;
}
export const Editor: FC<EditorProps> = ({ onUpdate: updateState, data: editor, ...props }) => {
const workspace = {
editing: useWorkspace((state) => state.editing),
onEditingNode: useWorkspace((state) => state.onEditingNode),
onEditingTree: useWorkspace((state) => state.onEditingTree),
open: useWorkspace((state) => state.open),
workdir: useWorkspace((state) => state.workdir),
};
const graphRef = useRef(null);
const sizeRef = useRef(null);
const editorSize = useSize(sizeRef);
const { t } = useTranslation();
const onChange = () => {
if (!editor.unsave) {
editor.unsave = true;
updateState();
}
};
const findParent = (node: TreeGraphData) => {
if (node.parent) {
return findDataById(node.parent);
} else {
return null;
}
};
const findHightlight = (node: TreeGraphData, highlight: string[], changed?: TreeGraphData[]) => {
changed ||= [];
if (node.highlightInput || node.highlightOutput) {
node.highlightInput = false;
node.highlightOutput = false;
changed.push(node);
}
if (node.input) {
for (const v of node.input) {
if (highlight.includes(v)) {
node.highlightInput = true;
changed.push(node);
break;
}
}
}
if (node.output) {
for (const v of node.output) {
if (highlight.includes(v)) {
node.highlightOutput = true;
changed.push(node);
break;
}
}
}
node.children?.forEach((child: TreeGraphData) => findHightlight(child, highlight, changed));
return changed;
};
const findDataById = (id: string) => {
return editor.graph.findDataById(id) as TreeGraphData;
};
const isAncestor = (ancestor: TreeGraphData, node: TreeGraphData): boolean => {
if (ancestor.id === node.parent) {
return true;
} else if (node.parent) {
return isAncestor(ancestor, findDataById(node.parent));
} else {
return false;
}
};
const isSubtreeNode = (node: TreeGraphData | null): boolean => {
if (node?.path) {
return true;
} else if (node?.parent) {
return isSubtreeNode(findParent(node));
} else {
return false;
}
};
const findSubtree = (node: TreeGraphData | null): TreeGraphData | null => {
if (node?.path) {
return node;
} else if (node?.parent) {
return findSubtree(findParent(node));
} else {
return null;
}
};
const reload = () => {
const rootData = findDataById("1");
const rootNode = b3util.createNode(rootData);
editor.data = b3util.createTreeData(rootNode);
editor.autoId = b3util.refreshTreeDataId(editor.data);
editor.graph.changeData(editor.data);
editor.graph.layout();
restoreViewport();
};
const checkSubtree = () => {
if (editor.graph) {
const data = findDataById("1");
if (b3util.isSubtreeUpdated(data)) {
reload();
pushHistory();
onChange();
}
}
};
const clearDragDstState = () => {
if (editor.dragDstId) {
setItemState(editor.dragDstId, "dragRight", false);
setItemState(editor.dragDstId, "dragDown", false);
setItemState(editor.dragDstId, "dragUp", false);
editor.dragDstId = undefined;
}
};
const clearDragSrcState = () => {
if (editor.dragSrcId) {
setItemState(editor.dragSrcId, "dragSrc", false);
editor.dragSrcId = undefined;
}
};
const setItemState = (item: string | Item, state: string, value: string | boolean) => {
if (typeof item === "string") {
item = editor.graph.findById(item);
}
if (item) {
editor.graph.setItemState(item, state, value);
}
};
const updateNode = (editNode: EditNode) => {
const newNode = editNode.data;
const data = findDataById(editNode.data.id.toString());
const oldNode = b3util.createNode(data, false);
if (b3util.isNodeEqual(oldNode, newNode)) {
return;
}
b3util.copyFromNode(data, newNode);
data.size = b3util.calcTreeDataSize(data);
if (oldNode.path !== newNode.path) {
reload();
selectNode(null);
selectNode(data.id);
} else {
const item = editor.graph.findById(data.id);
item.draw();
}
pushHistory();
onChange();
};
const updateTree = (editTree: EditTree) => {
if (editor.desc !== editTree.data.desc) {
editor.desc = editTree.data.desc || "";
onChange();
}
};
const selectNode = (id: string | null) => {
if (id && id === editor.selectedId) {
setItemState(editor.selectedId, "selected", true);
setItemState(editor.selectedId, "hover", false);
return;
}
if (editor.selectedId) {
setItemState(editor.selectedId, "selected", false);
setItemState(editor.selectedId, "hover", false);
}
editor.selectedId = id;
if (editor.selectedId) {
const data = findDataById(editor.selectedId);
workspace.onEditingTree(null);
workspace.onEditingNode({
data: b3util.createNode(data, false),
editable: !isSubtreeNode(data),
});
setItemState(editor.selectedId, "selected", true);
} else {
workspace.onEditingTree({
data: {
name: editor.name,
desc: editor.desc,
root: null!,
},
});
}
};
const restoreViewport = () => {
if (editor.graphMatrix) {
editor.graph.getGroup().setMatrix(editor.graphMatrix);
}
};
const pushHistory = () => {
editor.historyStack.length = ++editor.historyIndex;
editor.historyStack.push(b3util.createNode(findDataById("1")));
};
const copyNode = () => {
if (editor.selectedId) {
const data = findDataById(editor.selectedId);
if (data) {
const str = JSON.stringify(b3util.createNode(data), null, 2);
clipboard.writeText(str);
}
}
};
const pasteNode = () => {
if (!editor.selectedId) {
message.warning(t("node.noNodeSelected"));
return;
}
if (isSubtreeNode(findDataById(editor.selectedId))) {
message.warning(t("node.editSubtreeDenied"));
return;
}
try {
const str = clipboard.readText();
if (!str || str == "") {
return;
}
const curNodeData = findDataById(editor.selectedId);
const data = b3util.createTreeData(JSON.parse(str), editor.selectedId);
editor.autoId = b3util.refreshTreeDataId(data, editor.autoId);
selectNode(null);
curNodeData.children ||= [];
curNodeData.children.push(data);
updateGrahp();
pushHistory();
onChange();
} catch (error) {
message.error(t("node.pasteDataError"));
console.log(error);
}
};
const replaceNode = () => {
if (!editor.selectedId) {
message.warning(t("node.noNodeSelected"));
return;
}
if (isSubtreeNode(findDataById(editor.selectedId))) {
message.warning(t("node.editSubtreeDenied"));
return;
}
try {
const str = clipboard.readText();
if (!str || str == "") {
return;
}
const curNodeData = findDataById(editor.selectedId);
if (curNodeData.parent) {
const parentData = findDataById(curNodeData.parent);
const idx = parentData.children!.indexOf(curNodeData);
const data = b3util.createTreeData(JSON.parse(str), editor.selectedId);
editor.autoId = b3util.refreshTreeDataId(data, editor.autoId);
parentData.children![idx] = data;
updateGrahp();
} else {
editor.data = b3util.createTreeData(JSON.parse(str), editor.selectedId);
editor.autoId = b3util.refreshTreeDataId(editor.data);
editor.graph.changeData(editor.data);
editor.graph.render();
editor.graph.layout();
}
selectNode(null);
pushHistory();
onChange();
} catch (error) {
message.error(t("node.pasteDataError"));
console.log(error);
}
};
const createNode = () => {
if (!editor.selectedId) {
message.warning(t("node.noNodeSelected"));
return;
}
if (isSubtreeNode(findDataById(editor.selectedId))) {
message.warning(t("node.editSubtreeDenied"));
return;
}
const curNodeData = findDataById(editor.selectedId);
const newNodeData: NodeModel = {
id: editor.autoId++,
name: "unknow",
};
curNodeData.children ||= [];
curNodeData.children.push(b3util.createTreeData(newNodeData, editor.selectedId));
updateGrahp();
pushHistory();
onChange();
};
const deleteNode = () => {
if (!editor.selectedId) {
return;
}
if (editor.selectedId === "1") {
message.warning(t("node.deleteRootNodeDenied"));
return;
}
if (isSubtreeNode(findDataById(editor.selectedId))) {
message.warning(t("node.editSubtreeDenied"));
return;
}
const parentData = findParent(findDataById(editor.selectedId))!;
parentData.children = parentData.children!.filter((e) => e.id != editor.selectedId);
selectNode(null);
updateGrahp();
pushHistory();
onChange();
};
const useStackData = (data: NodeModel) => {
editor.data = b3util.createTreeData(data);
editor.autoId = b3util.refreshTreeDataId(editor.data);
editor.graph.changeData(editor.data);
editor.graph.layout();
restoreViewport();
onChange();
};
const updateGrahp = () => {
editor.graph.changeData();
editor.graph.layout();
};
const undo = () => {
if (editor.historyIndex > 0) {
const data = editor.historyStack[--editor.historyIndex];
useStackData(data);
}
};
const redo = () => {
if (editor.historyIndex < editor.historyStack.length - 1) {
const data = editor.historyStack[++editor.historyIndex];
useStackData(data);
}
};
const rename = (newPath: string) => {
editor.path = newPath;
editor.size.width = 0;
editor.size.height = 0;
};
const save = () => {
if (editor.unsave || !fs.existsSync(editor.path)) {
const path = editor.path;
const data = findDataById("1");
editor.autoId = b3util.refreshTreeDataId(data);
const root = b3util.createFileData(data);
const treeModel = {
name: Path.basenameWithoutExt(path),
root,
desc: editor.desc,
} as TreeModel;
fs.writeFileSync(path, JSON.stringify(treeModel, null, 2));
editor.unsave = false;
editor.data = b3util.createTreeData(root);
editor.autoId = b3util.refreshTreeDataId(editor.data);
editor.graph.changeData(editor.data);
editor.graph.layout();
restoreViewport();
updateState();
}
};
const editSubtree = () => {
if (!editor.selectedId) {
message.warning(t("node.noNodeSelected"));
return;
}
const d1 = findDataById(editor.selectedId);
const data = findSubtree(findDataById(editor.selectedId));
if (data?.path) {
const path = `${workspace.workdir}/${data.path}`;
workspace.open(path);
}
};
const saveAsSubtree = () => {
if (!editor.selectedId) {
message.warning(t("node.noNodeSelected"));
return;
}
if (editor.selectedId === "1") {
message.warning(t("node.saveAsSubtree"));
return;
}
setTimeout(() => {
const subpath = dialog.showSaveDialogSync({
defaultPath: workspace.workdir,
properties: ["showOverwriteConfirmation"],
filters: [{ name: "Json", extensions: ["json"] }],
});
if (!subpath) {
return;
}
if (subpath.indexOf(workspace.workdir) == -1) {
message.warning(t("node.subtreePathError"));
return;
}
const data = findDataById(editor.selectedId!);
const subroot = b3util.createFileData(data);
const subtreeModel = {
name: Path.basename(subpath).slice(0, -5),
root: subroot,
desc: data.desc,
} as TreeModel;
fs.writeFileSync(subpath, JSON.stringify(subtreeModel, null, 2));
data.path = subpath.substring(workspace.workdir.length + 1);
reload();
pushHistory();
onChange();
}, 200);
};
const destroyGraph = () => {
if (editor.graph) {
editor.graph.destroy();
editor.graph = undefined!;
}
};
const createGraph = (ref: React.MutableRefObject<null>) => {
editor.graph = new TreeGraph({
container: ref.current!,
animate: false,
maxZoom: 2,
minZoom: 0.4,
modes: {
default: [
"drag-canvas",
"zoom-canvas",
"click-select",
"hover",
{
type: "collapse-expand",
trigger: "dblclick",
onChange: (item, collapsed) => {
if (!item) {
return;
}
selectNode(item.getID());
const data = item.getModel();
data.collapsed = collapsed;
setItemState(item, "collapsed", data.collapsed as boolean);
const icon = data.collapsed ? G6.Marker.expand : G6.Marker.collapse;
const marker = item
.get("group")
.find((ele: any) => ele.get("name") === "collapse-icon");
editor.graph.refresh();
marker.attr("symbol", icon);
return true;
},
},
],
},
defaultEdge: {
type: "cubic-horizontal",
size: 2.5,
style: {
stroke: "#A3B1BF",
},
},
defaultNode: {
type: "TreeNode",
},
layout: {
type: "compactBox",
direction: "LR",
getHGap: () => 60,
getWidth: (d: TreeGraphData) => {
return 150;
},
getHeight: (d: TreeGraphData) => {
if (d.size) {
return d.size[1];
} else {
return 50;
}
},
},
});
editor.graph.on("viewportchange", (data: any) => {
if (data.action == "translate" || data.action == "zoom") {
editor.graphMatrix = data.matrix;
}
});
editor.graph.on("node:click", (e: G6GraphEvent) => {
const highlight: string[] = [];
if (e.shape.cfg.name === "input-text") {
const data = findDataById(e.item.getID());
data.input?.forEach((v) => v && highlight.push(v));
} else if (e.shape.cfg.name === "output-text") {
const data = findDataById(e.item.getID());
data.output?.forEach((v) => v && highlight.push(v));
}
const changed = findHightlight(findDataById("1"), highlight);
changed.forEach((v) => {
const item = editor.graph.findById(v.id);
item.draw();
});
});
editor.graph.on("node:contextmenu", (e: G6GraphEvent) => {
selectNode(e.item.getID());
});
editor.graph.on("node:mouseenter", (e: G6GraphEvent) => {
if (!e.item.hasState("selected")) {
setItemState(e.item, "hover", true);
}
});
editor.graph.on("node:mouseleave", (e: G6GraphEvent) => {
if (!e.item.hasState("selected")) {
setItemState(e.item, "hover", false);
}
});
editor.graph.on("nodeselectchange", (e: G6GraphEvent) => {
if (e.target) {
selectNode((e.target as unknown as Item).getID());
} else {
selectNode(null);
}
});
editor.graph.on("node:dragstart", (e: G6GraphEvent) => {
editor.dragSrcId = e.item.getID();
setItemState(editor.dragSrcId, "dragSrc", true);
});
editor.graph.on("node:dragend", (e: G6GraphEvent) => {
if (editor.dragSrcId) {
setItemState(editor.dragSrcId, "dragSrc", false);
editor.dragSrcId = undefined;
}
});
editor.graph.on("node:dragover", (e: G6GraphEvent) => {
const dstNodeId = e.item.getID();
if (dstNodeId === editor.dragSrcId) {
return;
}
clearDragDstState();
const box = e.item.getBBox();
if (e.x > box.minX + box.width * 0.6) {
setItemState(dstNodeId, "dragRight", true);
} else if (e.y > box.minY + box.height * 0.5) {
setItemState(dstNodeId, "dragDown", true);
} else {
setItemState(dstNodeId, "dragUp", true);
}
editor.dragDstId = dstNodeId;
});
editor.graph.on("node:dragleave", (e: G6GraphEvent) => {
clearDragDstState();
});
editor.graph.on("node:drop", (e: G6GraphEvent) => {
const srcNodeId = editor.dragSrcId;
const dstNode = e.item;
let dragDir;
if (dstNode.hasState("dragRight")) {
dragDir = "dragRight";
} else if (dstNode.hasState("dragDown")) {
dragDir = "dragDown";
} else if (dstNode.hasState("dragUp")) {
dragDir = "dragUp";
}
clearDragSrcState();
clearDragDstState();
if (!srcNodeId) {
console.log("no drag src");
return;
}
if (srcNodeId == dstNode.getID()) {
console.log("drop same node");
return;
}
const srcData = findDataById(srcNodeId);
const srcParent = findParent(srcData);
const dstData = findDataById(dstNode.getID());
const dstParent = findParent(dstData);
if (!srcParent) {
console.log("no parent!");
return;
}
if (isAncestor(srcData, dstData)) {
console.log("cannot move to child");
return;
}
const removeSrc = () => {
srcParent.children = srcParent.children!.filter((value) => value.id != srcData.id);
};
if (dragDir == "dragRight") {
removeSrc();
if (!dstData.children) {
dstData.children = [];
}
srcData.parent = dstData.id;
dstData.children.push(srcData);
} else if (dragDir == "dragUp") {
if (!dstParent) {
return;
}
removeSrc();
const idx = dstParent.children!.findIndex((value) => value.id == dstData.id);
srcData.parent = dstParent.id;
dstParent.children!.splice(idx, 0, srcData);
} else if (dragDir == "dragDown") {
if (!dstParent) {
return;
}
removeSrc();
const idx = dstParent.children!.findIndex((value) => value.id == dstData.id);
srcData.parent = dstParent.id;
dstParent.children!.splice(idx + 1, 0, srcData);
} else {
return;
}
updateGrahp();
pushHistory();
onChange();
});
editor.graph.data(editor.data);
editor.graph.render();
if (editor.graphMatrix) {
restoreViewport();
} else {
editor.graph.fitCenter();
}
};
const keysRef = useHotkeys<HTMLDivElement>(
[
Hotkey.Copy,
Hotkey.Paste,
Hotkey.Save,
Hotkey.Replace,
Hotkey.Undo,
Hotkey.Redo,
Hotkey.Insert,
Hotkey.Delete,
Hotkey.Enter,
Hotkey.Backspace,
],
(event) => {
event.preventDefault();
if (isHotkeyPressed(Hotkey.Copy)) {
copyNode();
} else if (isHotkeyPressed(Hotkey.Replace)) {
replaceNode();
} else if (isHotkeyPressed(Hotkey.Paste)) {
pasteNode();
} else if (isHotkeyPressed(Hotkey.Redo)) {
redo();
} else if (isHotkeyPressed(Hotkey.Undo)) {
undo();
} else if (isHotkeyPressed(Hotkey.Insert) || isHotkeyPressed(Hotkey.Enter)) {
createNode();
} else if (isHotkeyPressed(Hotkey.Delete) || isHotkeyPressed(Hotkey.Backspace)) {
deleteNode();
}
}
);
if (!editor.dispatch) {
editor.dispatch = (event: EditEvent, data: unknown) => {
switch (event) {
case "copy": {
copyNode();
break;
}
case "paste": {
pasteNode();
break;
}
case "delete": {
deleteNode();
break;
}
case "insert": {
createNode();
break;
}
case "replace": {
replaceNode();
break;
}
case "save": {
save();
break;
}
case "undo": {
undo();
break;
}
case "redo": {
redo();
break;
}
case "reload": {
reload();
break;
}
case "rename": {
rename(data as string);
break;
}
case "updateTree": {
updateTree(data as EditTree);
break;
}
case "updateNode": {
updateNode(data as EditNode);
break;
}
case "editSubtree": {
editSubtree();
break;
}
case "saveAsSubtree": {
saveAsSubtree();
break;
}
}
};
}
useEffect(() => {
if (!editorSize || (editorSize.width === 0 && editorSize.height === 0)) {
return;
}
if (editor.size.width !== editorSize.width || editor.size.height !== editorSize.height) {
destroyGraph();
}
if (!editor.graph) {
createGraph(graphRef);
editor.size.width = editorSize.width;
editor.size.height = editorSize.height;
}
});
useEffect(() => {
if (workspace.editing === editor) {
checkSubtree();
}
}, [workspace.editing]);
const menuItems = useMemo(() => {
const MenuItem: FC<FlexProps> = (itemProps) => {
return (
<Flex
gap="50px"
style={{ minWidth: "200px", justifyContent: "space-between", alignItems: "center" }}
{...itemProps}
></Flex>
);
};
const isMac = process.platform === "darwin";
const arr: MenuProps["items"] = [
{
label: (
<MenuItem>
<div>{t("copy")}</div>
<div>{isMac ? "⌘ C" : "Ctrl+C"}</div>
</MenuItem>
),
key: "copy",
},
{
label: (
<MenuItem>
<div>{t("paste")}</div>
<div>{isMac ? "⌘ V" : "Ctrl+V"}</div>
</MenuItem>
),
key: "paste",
},
{
label: (
<MenuItem>
<div>{t("replace")}</div>
<div>{isMac ? "⇧ ⌘ V" : "Ctrl+Shift+V"} </div>
</MenuItem>
),
key: "replace",
},
{
label: (
<MenuItem>
<div>{t("insertNode")}</div>
<div>{isMac ? <IoMdReturnLeft /> : "Enter"}</div>
</MenuItem>
),
key: "insert",
},
{
label: (
<MenuItem>
<div>{t("deleteNode")}</div>
<div>{isMac ? <FiDelete /> : "Backspace"}</div>
</MenuItem>
),
key: "delete",
},
{
label: (
<MenuItem>
<div>{t("editSubtree")}</div>
<div></div>
</MenuItem>
),
key: "editSubtree",
},
{
label: (
<MenuItem>
<div>{t("saveAsSubtree")}</div>
<div></div>
</MenuItem>
),
key: "saveAsSubtree",
},
];
return arr;
}, [t]);
useEffect(() => {
if (editor.graph) {
editor.graph.changeData(editor.data);
editor.graph.layout();
restoreViewport();
}
}, [t]);
type MenuInfo = Parameters<Exclude<MenuProps["onClick"], undefined>>[0];
const onClick = useCallback((info: MenuInfo) => {
editor.dispatch?.(info.key as EditEvent);
}, []);
return (
<div
{...props}
className="b3-editor"
ref={mergeRefs([sizeRef, keysRef])}
tabIndex={-1}
style={{ maxWidth: "inherit", maxHeight: "inherit" }}
>
<Dropdown menu={{ items: menuItems, onClick }} trigger={["contextMenu"]}>
<div tabIndex={-1} style={{ width: "100%", height: "100%" }} ref={graphRef} />
</Dropdown>
</div>
);
};
+738
View File
@@ -0,0 +1,738 @@
import { FileModel, useWorkspace } from "@/contexts/workspace-context";
import * as b3util from "@/misc/b3util";
import { modal } from "@/misc/hooks";
import { Hotkey, isHotkeyPressed, isMacos, useHotkeys } from "@/misc/keys";
import Path from "@/misc/path";
import { DownOutlined } from "@ant-design/icons";
import { Button, Dropdown, Flex, FlexProps, Input, MenuProps, Space, Tree } from "antd";
import { ItemType } from "antd/es/menu/hooks/useItems";
import { ipcRenderer, shell } from "electron";
import * as fs from "fs";
import React, { FC, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { FaExclamationTriangle, FaSwatchbook } from "react-icons/fa";
import { FiCommand, FiDelete } from "react-icons/fi";
import { IoMdReturnLeft } from "react-icons/io";
const { DirectoryTree } = Tree;
type MenuInfo = Parameters<Exclude<MenuProps["onClick"], undefined>>[0];
type MenuEvent =
| "open"
| "newFile"
| "newFolder"
| "revealFile"
| "rename"
| "delete"
| "paste"
| "copy"
| "move"
| "duplicate";
const findFile = (path: string | undefined, node: FileModel): FileModel | undefined => {
if (!path) {
return;
} else if (node.path === path) {
return node;
}
if (node.children) {
for (const child of node.children) {
const ret = findFile(path, child);
if (ret) {
return ret;
}
}
}
};
const findParent = (node: FileModel, parent?: FileModel): FileModel | undefined => {
if (parent && parent.children) {
if (parent.children?.indexOf(node) >= 0) {
return parent;
}
for (const child of parent.children) {
const v = findParent(node, child);
if (v) {
return v;
}
}
}
};
const resolveKeys = (path: string, node: FileModel, keys: React.Key[]) => {
if (node.path === path) {
return true;
}
if (node.children) {
keys.push(node.path);
for (const child of node.children) {
if (resolveKeys(path, child, keys)) {
return true;
}
}
keys.pop();
}
return false;
};
// rename file
const renameFile = (oldPath: string, newPath: string) => {
const workspace = useWorkspace.getState();
if (fs.existsSync(newPath)) {
return false;
} else if (oldPath !== newPath) {
try {
fs.renameSync(oldPath, newPath);
const isDirectory = fs.statSync(newPath).isDirectory();
for (const editor of workspace.editors) {
if (isDirectory) {
if (editor.path.startsWith(oldPath)) {
editor.dispatch?.("rename", editor.path.replace(oldPath, newPath));
}
} else {
if (editor.path === oldPath) {
editor.dispatch?.("rename", newPath);
}
}
}
return true;
} catch (e) {
console.error(e);
}
}
return false;
};
export const Explorer: FC = () => {
const workspace = {
close: useWorkspace((state) => state.close),
editing: useWorkspace((state) => state.editing),
editors: useWorkspace((state) => state.editors),
open: useWorkspace((state) => state.open),
fileTree: useWorkspace((state) => state.fileTree),
};
const { t } = useTranslation();
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>(
workspace.fileTree?.path ? [workspace.fileTree.path] : []
);
const [copyFile, setCopyFile] = useState("");
const [newName, setNewName] = useState<string | null>(null);
const [contextMenu, setContextMenu] = useState<ItemType[]>([]);
if (workspace.fileTree) {
workspace.fileTree.icon = <FaSwatchbook style={{ marginTop: "5px" }} />;
}
useEffect(() => {
if (workspace.editing) {
const keys: React.Key[] = [];
resolveKeys(workspace.editing.path, workspace.fileTree!, keys);
for (const k of expandedKeys) {
if (keys.indexOf(k) === -1) {
keys.push(k);
}
}
setExpandedKeys(keys);
setSelectedKeys([workspace.editing.path]);
}
}, [workspace.editing]);
const keysRef = useHotkeys<HTMLDivElement>(
[
Hotkey.F2,
Hotkey.Enter,
Hotkey.Copy,
Hotkey.Paste,
Hotkey.Delete,
Hotkey.MacDelete,
Hotkey.Escape,
Hotkey.Duplicate,
],
(event) => {
event.preventDefault();
const node = findFile(selectedKeys[0], workspace.fileTree!);
if (isHotkeyPressed(Hotkey.F2) || (isMacos && isHotkeyPressed(Hotkey.Enter))) {
if (node && node !== workspace.fileTree) {
node.editing = true;
setNewName("");
}
} else if (isHotkeyPressed(Hotkey.Delete) || isHotkeyPressed(Hotkey.MacDelete)) {
if (node && node !== workspace.fileTree) {
dispatch("delete", node);
}
} else if (isHotkeyPressed(Hotkey.Escape)) {
if (node) {
node.editing = false;
setNewName(null);
}
} else if (isHotkeyPressed(Hotkey.Duplicate)) {
if (node && node.isLeaf) {
dispatch("duplicate", node);
}
} else if (isHotkeyPressed(Hotkey.Copy)) {
if (node && node.isLeaf) {
dispatch("copy", node);
}
} else if (isHotkeyPressed(Hotkey.Paste)) {
if (node) {
dispatch("paste", node);
}
}
}
);
const submitRename = (node: FileModel) => {
if (!newName) {
if (fs.existsSync(node.path)) {
node.editing = false;
} else {
const parent = findParent(node, workspace.fileTree);
if (parent && parent.children) {
parent.children = parent.children.filter((v) => v !== node);
}
}
setNewName(null);
return;
}
if (node.path.endsWith("/:")) {
node.path = Path.dirname(node.path) + "/" + newName.replace(/[^\w. _-]+/g, "");
node.title = Path.basename(node.path);
fs.mkdirSync(node.path);
} else if (node.path.endsWith("/.json")) {
node.path = Path.dirname(node.path) + "/" + newName.replace(/[^\w. _-]+/g, "") + ".json";
node.title = Path.basename(node.path);
fs.writeFileSync(node.path, JSON.stringify(b3util.createNewTree(node.title), null, 2));
workspace.open(node.path);
} else {
const newpath = Path.dirname(node.path) + "/" + newName + Path.extname(node.path);
if (renameFile(node.path, newpath)) {
setSelectedKeys([newpath]);
}
}
node.editing = false;
setNewName(null);
};
const dispatch = (event: MenuEvent, node: FileModel, dest?: FileModel) => {
switch (event) {
case "open": {
workspace.open(node.path);
break;
}
case "newFolder": {
const folderNode: FileModel = {
path: node.path + "/:",
title: "",
children: [],
editing: true,
};
node.children?.unshift(folderNode);
setNewName("");
if (expandedKeys.indexOf(node.path) === -1) {
setExpandedKeys([node.path, ...expandedKeys]);
}
break;
}
case "newFile": {
const folderNode: FileModel = {
path: node.path + "/.json",
title: "",
isLeaf: true,
editing: true,
};
node.children?.unshift(folderNode);
setNewName("");
if (expandedKeys.indexOf(node.path) === -1) {
setExpandedKeys([node.path, ...expandedKeys]);
}
break;
}
case "paste": {
if (copyFile) {
let folder = node.path;
if (node.isLeaf) {
folder = Path.dirname(node.path);
}
const newPath = folder + "/" + Path.basename(copyFile);
if (fs.existsSync(newPath)) {
if (node.path === newPath) {
dispatch("duplicate", node);
} else {
const alert = modal.confirm({
centered: true,
content: (
<Flex vertical gap="middle">
<div>
<FaExclamationTriangle style={{ fontSize: "60px", color: "#FADB14" }} />
</div>
<div>{t("explorer.replaceFile", { name: Path.basename(copyFile) })}</div>
</Flex>
),
footer: (
<Flex vertical gap="middle" style={{ paddingTop: "30px" }}>
<Flex vertical gap="6px">
<Button
danger
onClick={() => {
workspace.close(newPath);
renameFile(node.path, newPath);
alert.destroy();
}}
>
{t("replace")}
</Button>
<Button
type="primary"
onClick={() => {
alert.destroy();
}}
>
{t("cancel")}
</Button>
</Flex>
</Flex>
),
});
}
} else {
fs.copyFileSync(copyFile, newPath);
}
}
break;
}
case "copy": {
setCopyFile(node.path);
break;
}
case "duplicate": {
for (let i = 1; ; i++) {
const dupName = Path.basenameWithoutExt(node.path) + " " + i + ".json";
const dupPath = Path.dirname(node.path) + "/" + dupName;
if (!fs.existsSync(dupPath)) {
fs.copyFileSync(node.path, dupPath);
setSelectedKeys([dupPath]);
break;
}
}
break;
}
case "delete": {
if (node === workspace.fileTree) {
return;
}
if (node.isLeaf) {
const alert = modal.confirm({
centered: true,
content: (
<Flex vertical gap="middle">
<div>
<FaExclamationTriangle style={{ fontSize: "60px", color: "#FADB14" }} />
</div>
<div>{t("explorer.deleteFile", { name: node.title })}</div>
</Flex>
),
footer: (
<Flex vertical gap="middle" style={{ paddingTop: "30px" }}>
<Flex vertical gap="6px">
<Button
onClick={() => {
alert.destroy();
if (node.path === workspace.editing?.path) {
workspace.close(node.path);
}
ipcRenderer.invoke("trashItem", node.path);
}}
>
{t("moveToTrash")}
</Button>
<Button
type="primary"
onClick={() => {
alert.destroy();
}}
>
{t("cancel")}
</Button>
</Flex>
<div style={{ fontSize: "11px", textAlign: "center" }}>
{t("explorer.restoreFileInfo")}
</div>
</Flex>
),
});
} else {
const alert = modal.confirm({
centered: true,
content: (
<Flex vertical gap="middle">
<div>
<FaExclamationTriangle style={{ fontSize: "60px", color: "#FADB14" }} />
</div>
<div>{t("explorer.deleteFolder", { name: node.title })}</div>
</Flex>
),
footer: (
<Flex vertical gap="middle" style={{ paddingTop: "30px" }}>
<Flex vertical gap="6px">
<Button
onClick={() => {
workspace.editors.forEach((editor) => {
if (editor.path.startsWith(node.path + "/")) {
workspace.close(editor.path);
}
});
ipcRenderer.invoke("trashItem", node.path);
alert.destroy();
}}
>
{t("moveToTrash")}
</Button>
<Button
type="primary"
onClick={() => {
alert.destroy();
}}
>
{t("cancel")}
</Button>
</Flex>
<div style={{ fontSize: "11px", textAlign: "center" }}>
{t("explorer.restoreFileInfo")}
</div>
</Flex>
),
});
}
break;
}
case "move": {
try {
const newPath = dest!.path + "/" + Path.basename(node.path);
const doMove = () => {
fs.renameSync(node.path, newPath);
for (const editor of workspace.editors) {
if (editor.path.startsWith(node.path)) {
editor.dispatch?.("rename", dest!.path + "/" + Path.basename(editor.path));
}
console.log("editor move", editor.path === newPath, editor.path, newPath);
if (editor.path.startsWith(newPath)) {
console.log("editor reload", editor.path === newPath, editor.path, newPath);
editor.dispatch?.("reload");
}
}
};
if (fs.existsSync(newPath)) {
const alert = modal.confirm({
centered: true,
content: (
<Flex vertical gap="middle">
<div>
<FaExclamationTriangle style={{ fontSize: "60px", color: "#FADB14" }} />
</div>
<div>{t("explorer.replaceFile", { name: Path.basename(node.path) })}</div>
</Flex>
),
footer: (
<Flex vertical gap="middle" style={{ paddingTop: "30px" }}>
<Flex vertical gap="6px">
<Button
danger
onClick={() => {
console.log("close file", newPath);
workspace.close(node.path);
doMove();
alert.destroy();
}}
>
{t("replace")}
</Button>
<Button
type="primary"
onClick={() => {
alert.destroy();
}}
>
{t("cancel")}
</Button>
</Flex>
</Flex>
),
});
} else {
doMove();
}
} catch (error) {
console.error("move file:", error);
}
break;
}
case "revealFile":
shell.showItemInFolder(node.path.replaceAll("/", Path.sep));
break;
case "rename": {
node.editing = true;
setNewName("");
break;
}
}
};
// context menu
const fileContextMenu = useMemo(() => {
const MenuItem: FC<FlexProps> = (itemProps) => {
return (
<Flex
gap="50px"
style={{ minWidth: "200px", justifyContent: "space-between", alignItems: "center" }}
{...itemProps}
></Flex>
);
};
const isMac = process.platform === "darwin";
const arr: MenuProps["items"] = [
{
label: (
<MenuItem>
<div>{t("open")}</div>
</MenuItem>
),
key: "open",
},
{
label: (
<MenuItem>
<div>{t("copy")}</div>
<div>{isMac ? "⌘ C" : "Ctrl+C"}</div>
</MenuItem>
),
key: "copy",
},
{
label: (
<MenuItem>
<div>{t("duplicate")}</div>
<div>{isMac ? "⌘ D" : "Ctrl+D"}</div>
</MenuItem>
),
key: "duplicate",
},
{
label: (
<MenuItem>
<div>{isMac ? t("revealFileOnMac") : t("revealFileOnWindows")}</div>
</MenuItem>
),
key: "revealFile",
},
{
label: (
<MenuItem>
<div>{t("rename")}</div>
{isMac && <IoMdReturnLeft />}
{!isMac && <div>F2</div>}
</MenuItem>
),
key: "rename",
},
{
label: (
<MenuItem>
<div>{t("delete")}</div>
{isMac && (
<Space size={6}>
<FiCommand />
<FiDelete />
</Space>
)}
</MenuItem>
),
key: "delete",
},
];
return arr;
}, [t]);
const directoryContextMenu = useMemo(() => {
const MenuItem: FC<FlexProps> = (itemProps) => {
return (
<Flex
gap="50px"
style={{ minWidth: "200px", justifyContent: "space-between", alignItems: "center" }}
{...itemProps}
></Flex>
);
};
const isMac = process.platform === "darwin";
const arr: MenuProps["items"] = [
{
label: (
<MenuItem>
<div>{t("newFile")}</div>
</MenuItem>
),
key: "newFile",
},
{
label: (
<MenuItem>
<div>{t("newFolder")}</div>
</MenuItem>
),
key: "newFolder",
},
{
label: (
<MenuItem>
<div>{isMac ? t("revealFileOnMac") : t("revealFileOnWindows")}</div>
</MenuItem>
),
key: "revealFile",
},
{
label: (
<MenuItem>
<div style={{ color: copyFile ? "inherit" : "gray" }}>{t("paste")}</div>
<div style={{ color: copyFile ? "inherit" : "gray" }}>{isMac ? "⌘ V" : "Ctrl+V"}</div>
</MenuItem>
),
key: "paste",
},
{
label: (
<MenuItem>
<div>{t("rename")}</div>
{isMac && <IoMdReturnLeft />}
{!isMac && <div>F2</div>}
</MenuItem>
),
key: "rename",
},
{
label: (
<MenuItem>
<div>{t("delete")}</div>
{isMac && (
<Space size={6}>
<FiCommand />
<FiDelete />
</Space>
)}
</MenuItem>
),
key: "delete",
},
];
return arr;
}, [t, copyFile]);
const onClick = (info: MenuInfo) => {
const node = findFile(selectedKeys[0], workspace.fileTree!) ?? workspace.fileTree;
if (node) {
dispatch(info.key as MenuEvent, node);
}
};
if (!workspace.fileTree) {
return null;
}
return (
<div
ref={keysRef}
tabIndex={-1}
style={{ height: "100%" }}
onContextMenuCapture={() => {
setContextMenu(directoryContextMenu);
}}
onContextMenu={(e) => {
if (workspace.fileTree) {
// setSelectedKeys([workspace.fileTree.path]);
}
}}
>
<div style={{ padding: "12px 24px" }}>
<span style={{ fontSize: "18px", fontWeight: "600" }}>{t("explorer.title")}</span>
</div>
<Flex vertical style={{ overflow: "auto", height: "100%" }}>
<Dropdown menu={{ items: contextMenu, onClick }} trigger={["contextMenu"]}>
<div>
<DirectoryTree
tabIndex={-1}
treeData={workspace.fileTree ? [workspace.fileTree] : []}
fieldNames={{ key: "path" }}
onExpand={(keys) => {
setExpandedKeys(keys);
}}
onRightClick={(info) => {
if (info.node.isLeaf) {
setContextMenu(fileContextMenu);
}
setSelectedKeys([info.node.path]);
}}
onSelect={(_, info) => {
const node = info.selectedNodes.at(0);
if (node) {
if (!node.children) {
workspace.open(node.path);
}
setSelectedKeys([node.path]);
}
if (node && !node.children) {
workspace.open(node.path);
}
}}
onDrop={(info) => {
dispatch("move", info.dragNode, info.node);
}}
titleRender={(node) => {
if (node.editing) {
return (
<div style={{ display: "inline-flex" }}>
<Input
defaultValue={Path.basenameWithoutExt(node.title)}
autoFocus
style={{ padding: "0px 0px", borderRadius: "2px" }}
onFocus={(e) => e.target.select()}
onChange={(e) => setNewName(e.target.value)}
onPressEnter={() => {
if (newName) {
submitRename(node);
}
}}
onClick={(e) => e.stopPropagation()}
onKeyUp={(e) => {
if (e.code === Hotkey.Escape) {
if (!fs.existsSync(node.path)) {
const parent = findParent(node, workspace.fileTree);
if (parent && parent.children) {
parent.children = parent.children.filter((v) => v !== node);
}
}
node.editing = false;
setNewName(null);
}
}}
onBlur={() => submitRename(node)}
></Input>
</div>
);
} else {
return <span key={node.path}>{node.title}</span>;
}
}}
allowDrop={(options) => {
return !options.dropNode.isLeaf;
}}
defaultExpandedKeys={[workspace.fileTree.path]}
draggable={newName !== null ? false : { icon: false }}
expandedKeys={expandedKeys}
switcherIcon={<DownOutlined />}
selectedKeys={selectedKeys}
/>
</div>
</Dropdown>
</Flex>
</div>
);
};
+359
View File
@@ -0,0 +1,359 @@
import { EditNode, EditTree, FileModel, useWorkspace } from "@/contexts/workspace-context";
import { NodeModel, TreeGraphData, TreeModel } from "@/misc/b3type";
import { Hotkey } from "@/misc/keys";
import Path from "@/misc/path";
import { AutoComplete, Divider, Flex, Form, Input, InputNumber, Select, Switch } from "antd";
import { DefaultOptionType } from "antd/es/select";
import { FC, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import Markdown from "react-markdown";
interface OptionType extends DefaultOptionType {}
export const Inspector: FC = () => {
const workspace = {
editing: useWorkspace((state) => state.editing),
editingNode: useWorkspace((state) => state.editingNode),
editingTree: useWorkspace((state) => state.editingTree),
getNodeDef: useWorkspace((state) => state.getNodeDef),
nodeDefs: useWorkspace((state) => state.nodeDefs),
onEditingNode: useWorkspace((state) => state.onEditingNode),
allFiles: useWorkspace((state) => state.allFiles),
fileTree: useWorkspace((state) => state.fileTree),
};
const { t } = useTranslation();
const [form] = Form.useForm();
// set form values
useEffect(() => {
if (workspace.editingTree) {
const data = workspace.editingTree.data;
form.resetFields();
form.setFieldValue("name", data.name);
form.setFieldValue("desc", data.desc);
} else if (workspace.editingNode) {
const data = workspace.editingNode.data;
const def = workspace.getNodeDef(workspace.editingNode.data.name);
form.resetFields();
form.setFieldValue("id", data.id);
form.setFieldValue("name", data.name);
form.setFieldValue("desc", data.desc);
form.setFieldValue("debug", data.debug);
form.setFieldValue("path", data.path);
def.args?.forEach((v) => {
form.setFieldValue(`args.${v.name}`, data.args?.[v.name]);
});
def.input?.forEach((_, i) => {
form.setFieldValue(`input.${i}`, data.input?.[i]);
});
def.output?.forEach((_, i) => {
form.setFieldValue(`output.${i}`, data.output?.[i]);
});
}
}, [workspace.editingNode, workspace.editingTree]);
// auto complete for node
const nodeOptions = useMemo(() => {
const options: OptionType[] = [];
workspace.nodeDefs.forEach((e) => {
options.push({ label: `${e.name}(${e.desc})`, value: e.name });
});
return options;
}, [workspace.nodeDefs]);
// auto complete for input and output
const inoutVarOptions = useMemo(() => {
const options: OptionType[] = [];
const collect = (node?: TreeGraphData) => {
if (node) {
node.input?.forEach((v, i) => {
const desc = node.def.input?.[i] ?? "<unknown>";
if (v && !options.find((option) => option.value !== v)) {
options.push({ label: `${v}(${desc})`, value: v });
}
});
node.output?.forEach((v, i) => {
const desc = node.def.output?.[i] ?? "<unknown>";
if (v && !options.find((option) => option.value !== v)) {
options.push({ label: `${v}(${desc})`, value: v });
}
});
node.children?.forEach((child) => collect(child));
}
};
collect(workspace.editing?.data);
return options;
}, [workspace.editing]);
// auto complete for subtree
const subtreeOptions = useMemo(() => {
const options: OptionType[] = [];
workspace.allFiles.forEach((path) => {
const value = Path.relative(workspace.fileTree!.path, path);
const desc = ""; //fileNode.desc ? `(${fileNode.desc})` : "";
options.push({
label: `${value}${desc}`,
value: value,
});
});
return options;
}, [workspace.allFiles, workspace.fileTree]);
if (workspace.editingTree) {
const finish = (values: any) => {
const data = {} as TreeModel;
data.name = values.name;
data.desc = values.desc || undefined;
workspace.editing?.dispatch?.("updateTree", {
data: {
name: values.name,
desc: values.desc || undefined,
},
} as EditTree);
};
return (
<Flex className="b3-inspector" vertical style={{ height: "100%" }}>
<div style={{ padding: "12px 24px" }}>
<span style={{ fontSize: "18px", fontWeight: "600" }}>{t("tree.overview")}</span>
</div>
<div style={{ overflow: "auto", height: "100%", padding: "24px" }}>
<Form form={form} labelCol={{ span: 8 }} onFinish={finish}>
<Form.Item name="name" label={t("tree.name")}>
<Input disabled={true} />
</Form.Item>
<Form.Item name="desc" label={t("tree.desc")}>
<Input onBlur={form.submit} />
</Form.Item>
</Form>
</div>
</Flex>
);
} else if (workspace.editingNode) {
const editingNode = workspace.editingNode;
const def = workspace.getNodeDef(editingNode.data.name);
const disabled = !editingNode.editable;
// update value
const finish = (values: any) => {
const data = {} as NodeModel;
data.id = editingNode.data.id;
data.name = values.name;
data.debug = values.debug || undefined;
data.desc = values.desc || undefined;
data.path = values.path || undefined;
def.args?.forEach((arg) => {
const v = values[`args.${arg.name}`];
if (v !== null && v !== undefined && v !== "") {
data.args ||= {};
data.args[arg.name] = v;
}
});
def.input?.forEach((_, i) => {
const v = values[`input.${i}`];
data.input ||= [];
data.input.push(v ?? "");
});
def.output?.forEach((_, i) => {
const v = values[`output.${i}`];
data.output ||= [];
data.output!.push(v ?? "");
});
workspace.editing?.dispatch?.("updateNode", {
data: data,
} as EditNode);
};
// change node def
const changeNodeDef = (newname: string) => {
if (editingNode.data.name !== newname) {
editingNode.data = {
id: editingNode.data.id,
name: workspace.nodeDefs.get(newname)?.name ?? newname,
desc: editingNode.data.desc,
debug: editingNode.data.debug,
};
workspace.onEditingNode(editingNode);
form.submit();
} else {
form.submit();
}
};
const changeSubtree = () => {
if (form.getFieldValue("path") !== editingNode.data.path) {
finish(form.getFieldsValue());
} else {
form.submit();
}
};
return (
<Flex className="b3-inspector" vertical style={{ height: "100%" }}>
<div style={{ padding: "12px 24px" }}>
<span style={{ fontSize: "18px", fontWeight: "600" }}>{def.desc}</span>
</div>
<div style={{ overflow: "auto", height: "100%", padding: "24px" }}>
<Form
form={form}
wrapperCol={{ span: "auto" }}
labelCol={{ span: "auto" }}
onFinish={finish}
>
<Form.Item
name="id"
label={
<div style={{ minWidth: "80px", justifyContent: "flex-end" }}>{t("node.id")}</div>
}
>
<Input disabled={true} />
</Form.Item>
<Form.Item label={t("node.name")} name="name">
<AutoComplete
disabled={disabled}
options={nodeOptions}
onBlur={() => changeNodeDef(form.getFieldValue("name"))}
onSelect={changeNodeDef}
onInputKeyDown={(e) => e.code === Hotkey.Escape && e.preventDefault()}
filterOption={(inputValue: string, option?: OptionType) => {
const label = option!.label as string;
return label.toUpperCase().indexOf(inputValue.toUpperCase()) !== -1;
}}
/>
</Form.Item>
<Form.Item name="desc" label={t("node.desc")}>
<Input disabled={disabled} onBlur={form.submit} />
</Form.Item>
<Form.Item label={t("node.debug")} name="debug" valuePropName="checked">
<Switch disabled={disabled && !editingNode.data.path} onChange={form.submit} />
</Form.Item>
<Form.Item label={t("node.subtree")} name="path">
<AutoComplete
disabled={disabled && !editingNode.data.path}
options={subtreeOptions}
onBlur={changeSubtree}
onInputKeyDown={(e) => e.code === Hotkey.Escape && e.preventDefault()}
filterOption={(inputValue: string, option?: OptionType) => {
const label = option!.label as string;
return label.toUpperCase().indexOf(inputValue.toUpperCase()) !== -1;
}}
/>
</Form.Item>
<Markdown>{def.doc}</Markdown>
{def.input?.length && (
<>
<Divider orientation="left">
<h4>{t("node.inputVariable")}</h4>
</Divider>
{def.input.map((v, i) => {
const required = v.indexOf("?") == -1;
const desc = v.replace("?", "");
return (
<Form.Item
label={desc}
name={`input.${i}`}
key={`input.${i}`}
rules={[{ required, message: t("node.fileRequired", { field: desc }) }]}
>
<AutoComplete
disabled={disabled}
options={inoutVarOptions}
onBlur={form.submit}
onInputKeyDown={(e) => e.code === Hotkey.Escape && e.preventDefault()}
filterOption={(inputValue: string, option?: OptionType) => {
const label = option!.label as string;
return label.toUpperCase().indexOf(inputValue.toUpperCase()) !== -1;
}}
/>
</Form.Item>
);
})}
</>
)}
{def.args?.length && (
<>
<Divider orientation="left">
<h4>{t("node.args")}</h4>
</Divider>
{def.args.map((v) => {
const required = v.type.indexOf("?") == -1;
return (
<Form.Item
initialValue={v.default}
name={`args.${v.name}`}
label={v.desc}
key={`args.${v.name}`}
valuePropName={v.type.indexOf("boolean") >= 0 ? "checked" : undefined}
rules={[{ required, message: t("node.fileRequired", { field: v.desc }) }]}
>
{v.type.indexOf("string") >= 0 && (
<Input disabled={disabled} onBlur={form.submit} />
)}
{v.type.indexOf("int") >= 0 && (
<InputNumber disabled={disabled} onBlur={form.submit} precision={0} />
)}
{v.type.indexOf("float") >= 0 && (
<InputNumber disabled={disabled} onBlur={form.submit} />
)}
{v.type.indexOf("boolean") >= 0 && (
<Switch disabled={disabled} onChange={form.submit} />
)}
{v.type.indexOf("code") >= 0 && (
<Input onBlur={form.submit} placeholder={t("node.codePlaceholder")} />
)}
{v.type.indexOf("enum") >= 0 && (
<Select disabled={disabled} onBlur={form.submit} onChange={form.submit}>
{v.options?.map((value) => {
return (
<Select.Option key={value.name} value={value.value}>
{value.name}
</Select.Option>
);
})}
</Select>
)}
</Form.Item>
);
})}
</>
)}
{def.output?.length && (
<>
<Divider orientation="left">
<h4>{t("node.outputVariable")}</h4>
</Divider>
{def.output.map((v, i) => {
const required = v.indexOf("?") == -1;
const desc = v.replace("?", "");
return (
<Form.Item
label={desc}
name={`output.${i}`}
key={`output.${i}`}
rules={[{ required, message: t("node.fileRequired", { field: desc }) }]}
>
<AutoComplete
disabled={disabled}
options={inoutVarOptions}
onBlur={form.submit}
onInputKeyDown={(e) => e.code === Hotkey.Escape && e.preventDefault()}
filterOption={(inputValue: string, option?: OptionType) => {
const label = option!.label as string;
return label.toUpperCase().indexOf(inputValue.toUpperCase()) !== -1;
}}
/>
</Form.Item>
);
})}
</>
)}
</Form>
</div>
</Flex>
);
} else {
return null;
}
};
+457
View File
@@ -0,0 +1,457 @@
import { useSetting } from "@/contexts/setting-context";
import { useWorkspace } from "@/contexts/workspace-context";
import * as b3util from "@/misc/b3util";
import i18n from "@/misc/i18n";
import { Hotkey, sendInputEvent } from "@/misc/keys";
import { CheckOutlined } from "@ant-design/icons";
import { Menu as AppMenu, BrowserWindow, app, dialog } from "@electron/remote";
import { Button, DropDownProps, Dropdown, Flex, FlexProps, LayoutProps, Space } from "antd";
import { ItemType } from "antd/es/menu/hooks/useItems";
import { ipcRenderer } from "electron";
import { MenuItemConstructorOptions } from "electron/renderer";
import * as fs from "fs";
import { FC, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { PiTreeStructureFill } from "react-icons/pi";
const MenuItemLabel: FC<FlexProps> = (itemProps) => {
return (
<Flex
gap="50px"
style={{ minWidth: "160px", justifyContent: "space-between", alignItems: "center" }}
{...itemProps}
></Flex>
);
};
const getFocusedWebContents = () => {
return BrowserWindow.getFocusedWindow()?.webContents;
};
ipcRenderer.on("open-project", (_, dir) => {
useWorkspace.getState().init(dir);
});
ipcRenderer.on("refresh-app-men", () => {
// trigger refresh
useSetting.getState().load();
});
export const Menu: FC<LayoutProps> = () => {
const { t } = useTranslation();
const [trigger, setTrigger] = useState<DropDownProps["trigger"]>(["click"]);
const workspace = {
allFiles: useWorkspace((state) => state.allFiles),
batchProject: useWorkspace((state) => state.batchProject),
buildProject: useWorkspace((state) => state.buildProject),
createProject: useWorkspace((state) => state.createProject),
editing: useWorkspace((state) => state.editing),
editors: useWorkspace((state) => state.editors),
init: useWorkspace((state) => state.init),
open: useWorkspace((state) => state.open),
openProject: useWorkspace((state) => state.openProject),
path: useWorkspace((state) => state.path),
save: useWorkspace((state) => state.save),
saveAll: useWorkspace((state) => state.saveAll),
workdir: useWorkspace((state) => state.workdir),
};
const enabled = !!workspace.workdir;
const homedir = app.getPath("home");
const settings = {
recent: useSetting((state) => state.recent),
};
const isMac = process.platform === "darwin";
const menuTemplate: MenuItemConstructorOptions[] = useMemo(() => {
const recentWorkspaces: MenuItemConstructorOptions[] = settings.recent.map((path, i) => ({
id: "menu.file.recent" + i,
label: path.replace(homedir, "~"),
click: () => workspace.openProject(path),
}));
return [
// { role: 'appMenu' }
...(isMac
? [
{
id: "menu.app",
label: app.name,
submenu: [
{ label: t("about"), role: "about" },
{ type: "separator" },
{ label: t("services"), role: "services" },
{ type: "separator" },
{ label: t("hide"), role: "hide" },
{ label: t("hideOthers"), role: "hideOthers" },
{ label: t("unhide"), role: "unhide" },
{ type: "separator" },
{ label: t("quit"), role: "quit" },
],
},
]
: []),
{
id: "menu.file",
label: t("menu.file"),
submenu: [
{
id: "menu.file.newFile",
label: t("newFile"),
accelerator: "CmdOrCtrl+N",
enabled: enabled,
click: () => {
console.log("newFile");
const path = dialog.showSaveDialogSync({
properties: ["showOverwriteConfirmation"],
filters: [{ name: "Json", extensions: ["json"] }],
});
if (path) {
fs.writeFileSync(path, JSON.stringify(b3util.createNewTree(path), null, 2));
workspace.open(path);
}
},
},
{
id: "menu.file.newWindow",
label: t("newWindow"),
click: () => {
ipcRenderer.invoke("open-win");
},
},
{ type: "separator" },
{
id: "menu.file.openProject",
label: t("openProject"),
click: () => workspace.openProject(),
},
{
id: "menu.file.createProject",
label: t("createProject"),
click: () => workspace.createProject(),
},
{ id: "menu.file.recent", label: t("recent"), submenu: recentWorkspaces },
{ type: "separator" },
{
id: "menu.file.save",
label: t("save"),
accelerator: "CmdOrCtrl+S",
enabled: enabled,
click: () => {
console.log("save1");
workspace.save();
},
},
{
id: "menu.file.saveAs",
label: t("saveAs"),
accelerator: "Shift+CmdOrCtrl+S",
enabled: enabled,
},
{
id: "menu.file.saveAll",
label: t("saveAll"),
accelerator: "Alt+CmdOrCtrl+S",
enabled: enabled,
click: () => workspace.saveAll(),
},
{ type: "separator" },
{
id: "menu.file.build",
label: t("build"),
accelerator: "CmdOrCtrl+B",
enabled: enabled,
click: () => {
workspace.buildProject();
},
},
{
id: "menu.file.batch",
label: t("batch"),
enabled: enabled,
click: () => workspace.batchProject(),
},
],
},
{
id: "menu.edit",
label: t("menu.edit"),
submenu: [
{
id: "menu.edit.undo",
label: t("undo"),
enabled: enabled,
accelerator: Hotkey.Undo,
click: () => {
sendInputEvent(Hotkey.Undo);
getFocusedWebContents()?.undo();
},
},
{
id: "menu.edit.redo",
label: t("redo"),
enabled: enabled,
accelerator: Hotkey.Redo,
click: () => {
sendInputEvent(Hotkey.Redo);
getFocusedWebContents()?.redo();
},
},
{ type: "separator" },
{
id: "menu.edit.copy",
label: t("copy"),
enabled: enabled,
accelerator: Hotkey.Copy,
click: () => {
sendInputEvent(Hotkey.Copy);
getFocusedWebContents()?.copy();
},
},
{
id: "menu.edit.paste",
label: t("paste"),
enabled: enabled,
accelerator: Hotkey.Paste,
click: () => {
sendInputEvent(Hotkey.Paste);
getFocusedWebContents()?.paste();
},
},
{ type: "separator" },
{
id: "menu.edit.insertNode",
label: t("insertNode"),
enabled: enabled,
click: () => {
workspace.editing?.dispatch?.("insert");
},
},
{
id: "menu.edit.deleteNode",
label: t("deleteNode"),
enabled: enabled,
click: () => {
workspace.editing?.dispatch?.("delete");
},
},
],
},
// { role: 'viewMenu' }
{
id: "menu.view",
label: t("menu.view"),
submenu: [
{
id: "menu.view.reload",
label: t("reload"),
accelerator: "CmdOrCtrl+R",
click: () => {
const path = workspace.path;
getFocusedWebContents()?.reload();
workspace.init(path);
},
},
{
id: "menu.view.devTools",
label: t("devTools"),
accelerator: "Alt+CmdOrCtrl+I",
click: () => {
getFocusedWebContents()?.openDevTools();
},
},
{ type: "separator" },
{
id: "menu.view.actualSize",
label: t("actualSize"),
click: () => {
const webContents = getFocusedWebContents();
if (webContents) {
webContents.setZoomFactor(1);
}
},
},
{
id: "menu.view.zoomIn",
label: t("zoomIn"),
role: "zoomIn",
click: () => {
const webContents = getFocusedWebContents();
if (webContents) {
webContents.setZoomFactor(webContents.getZoomFactor() + 0.1);
}
},
},
{
id: "menu.view.zoomOut",
label: t("zoomOut"),
click: () => {
const webContents = getFocusedWebContents();
if (webContents) {
webContents.setZoomFactor(webContents.getZoomFactor() - 0.1);
}
},
},
{ type: "separator" },
{
id: "menu.view.fullscreen",
label: t("fullscreen"),
click: () => {
const window = BrowserWindow.getFocusedWindow();
if (window) {
window.setFullScreen(!window.isFullScreen());
}
},
},
{ type: "separator" },
{
id: "menu.view.language",
label: t("language"),
submenu: [
{
id: "menu.view.language.english",
label: "English",
type: "radio",
checked: i18n.language?.startsWith("en"),
click: () => {
i18n.changeLanguage("en");
},
},
{
id: "menu.view.language.chinese",
label: "简体中文",
type: "radio",
checked: i18n.language?.startsWith("zh"),
click: () => {
i18n.changeLanguage("zh");
},
},
],
},
],
},
{
id: "menu.help",
label: t("menu.help"),
submenu: [
{
id: "menu.help.about",
label: t("reportIssue"),
click: () => {
window.open("https://github.com/zhandouxiaojiji/behavior3editor");
},
},
],
},
// {
// id: "menu.window",
// label: t("menu.window"),
// submenu: [
// { id: "menu.window.minimize", role: "minimize" },
// { id: "menu.window.zoom", role: "zoom" },
// ...(isMac
// ? [
// { type: "separator" },
// { id: "menu.window.front", role: "front" },
// { type: "separator" },
// { id: "menu.window.window", role: "window" },
// ]
// : [{ id: "menu.window.close", role: "close" }]),
// ],
// },
] as MenuItemConstructorOptions[];
}, [t, workspace.workdir, settings.recent, workspace.editing]);
if (isMac) {
const menu = AppMenu.buildFromTemplate(menuTemplate);
AppMenu.setApplicationMenu(menu);
}
if (!process.env.VITE_DEV_SERVER_URL && isMac) {
return <div />;
}
return (
<Flex className="b3-menu" vertical={false} align="center" style={{ paddingLeft: "10px" }}>
<PiTreeStructureFill size="25px" style={{ paddingRight: "5px" }} />
<Space size={2} className="b3-no-drag-region">
{menuTemplate.map((item, index) => {
const items = item.submenu as MenuItemConstructorOptions[];
if (item.label === app.name) {
return undefined;
}
const createItem = (option: MenuItemConstructorOptions): ItemType => {
if (option.type === "separator") {
return {
type: "divider",
};
}
const keys = (option.accelerator?.split("+") ?? []).map((key) => {
const lowerKey = key.toLowerCase();
if (lowerKey.indexOf("alt") !== -1) {
return isMac ? "⌥" : "Alt";
} else if (
lowerKey.indexOf("cmdorctrl") !== -1 ||
lowerKey.indexOf("ctrl") !== -1 ||
lowerKey.indexOf("meta") !== -1
) {
return isMac ? "⌘" : "Ctrl";
} else if (lowerKey.indexOf("shift") !== -1) {
return isMac ? "⇧" : "Shift";
} else {
return key.toUpperCase().replace("=", "+");
}
});
const ctrl = keys.indexOf("ctrl");
if (ctrl >= 0) {
keys.splice(ctrl, 1);
keys.unshift("Ctrl");
}
const subItems = option.submenu as MenuItemConstructorOptions[] | undefined;
return {
label: (
<MenuItemLabel>
<div>
{option.type === "radio" && option.checked && (
<CheckOutlined style={{ paddingRight: "5px" }} />
)}
{option.type === "radio" && !option.checked && (
<div
style={{
display: "inline-block",
width: "20px",
}}
></div>
)}
{option.label}
</div>
<div>{keys.join(isMac ? " " : "+")}</div>
</MenuItemLabel>
),
key: option.id ?? "",
disabled: option.enabled === false,
onClick: () => {
option.click?.(null!, null!, null!);
},
children: subItems?.map((subOption) => createItem(subOption)),
};
};
return (
<Dropdown
trigger={trigger}
key={`menu${index}`}
dropdownRender={(node) => {
return <div className="b3-app-menu">{node}</div>;
}}
menu={{
items: items.map((value) => createItem(value)),
}}
>
<Button type="text" size="small" onClick={() => setTrigger(["hover"])}>
{item.label}
</Button>
</Dropdown>
);
})}
</Space>
</Flex>
);
};
+412
View File
@@ -0,0 +1,412 @@
import { useWorkspace } from "@/contexts/workspace-context";
import { toBreakWord } from "@/misc/b3util";
import i18n from "@/misc/i18n";
import G6 from "@antv/g6";
const NODE_COLORS: any = {
["Composite"]: "rgb(91,237,32)",
["Decorator"]: "rgb(218,167,16)",
["Condition"]: "rgb(228,20,139)",
["Action"]: "rgb(91,143,249)",
["Other"]: "rgb(112,112,112)",
["Error"]: "rgb(255,0,0)",
};
const SELECTED_LINE_WIDTH = 4;
G6.registerNode(
"TreeNode",
{
options: {
type: "rect",
anchorPoints: [
[0, 0.5],
[1, 0.5],
],
labelCfg: {
style: {
fill: "blue",
fontSize: 10,
},
},
style: {
fill: "white",
},
stateStyles: {
selected: {
"main-box": {
stroke: "yellow",
lineWidth: SELECTED_LINE_WIDTH,
},
"name-bg-selected": {
fill: "yellow",
},
},
hover: {
stroke: "white",
lineWidth: 3,
},
dragSrc: {
fill: "gray",
},
dragRight: {
"drag-right": {
fillOpacity: 1,
strokeOpacity: 1,
},
},
dragUp: {
"drag-up": {
fillOpacity: 1,
strokeOpacity: 1,
},
},
dragDown: {
"drag-down": {
fillOpacity: 1,
strokeOpacity: 1,
},
},
},
},
draw(cfg, group) {
const workspace = useWorkspace.getState();
const nodeDef = workspace.getNodeDef(cfg.name as string);
let classify = nodeDef.type || "Other";
let color = nodeDef.color || NODE_COLORS[classify] || NODE_COLORS["Other"];
if (
!workspace.hasNodeDef(cfg.name as string) ||
(cfg.path && (!cfg.children || (cfg.children as []).length == 0))
) {
classify = "Error";
color = NODE_COLORS[classify];
}
const size = cfg.size ? (cfg.size as number[]) : [150, 40];
const w = size[0];
const h = size[1];
const r = 4;
type ShapeCfg = Parameters<typeof group.addShape>[1];
const addShape = (type: string, shapeCfg: ShapeCfg, clickEnabled: boolean = false) => {
shapeCfg.draggable = clickEnabled;
shapeCfg.capture = clickEnabled;
return group.addShape(type, shapeCfg);
};
const shape = addShape(
"rect",
{
attrs: {
x: 0,
y: 0,
width: w,
height: h,
stroke: color,
lineWidth: 2,
fill: "white",
radius: r,
},
name: "main-box",
draggable: true,
},
true
);
// is subtree
if (cfg.path && cfg.id !== "1") {
addShape("rect", {
attrs: {
x: -10,
y: -10,
width: w + 20,
height: h + 20,
stroke: "white",
lineWidth: 2.5,
lineDash: [5, 5],
radius: [r, r, r, r],
},
name: "subtree",
draggable: true,
});
}
// name bg
addShape("rect", {
attrs: {
x: 0,
y: 0,
width: w,
height: 20,
fill: color,
radius: [r, r, 0, 0],
},
name: "name-bg-selected",
draggable: true,
});
addShape("rect", {
attrs: {
x: SELECTED_LINE_WIDTH / 2,
y: SELECTED_LINE_WIDTH / 2,
width: w - 4,
height: 20 - SELECTED_LINE_WIDTH / 2,
fill: color,
radius: [r, r, 0, 0],
},
name: "name-bg",
draggable: true,
});
// id text
addShape("text", {
attrs: {
textBaseline: "top",
x: -3,
y: h / 2 - 8,
fontSize: 20,
lineHeight: 20,
text: cfg.id,
textAlign: "right",
fill: "white",
stroke: "black",
lineWidth: 2,
},
name: "id-text",
});
// icon
const img = `./icons/${classify}.svg`;
addShape("image", {
attrs: {
x: 5,
y: 2,
height: 14,
width: 14,
img,
},
name: "node-icon",
});
// debug
if (cfg.debug) {
addShape("image", {
attrs: {
x: 182,
y: 2,
height: 14,
width: 14,
img: `./icons/Debug.svg`,
},
name: "node-icon",
});
}
// name text
addShape("text", {
attrs: {
textBaseline: "top",
x: 22,
y: 3,
fontWeight: 800,
lineHeight: 20,
text: cfg.name,
fill: "black",
},
name: "name-text",
});
const x = 5;
let y = 24;
// desc text
const desc = cfg.desc || nodeDef.desc;
if (desc) {
addShape("text", {
attrs: {
textBaseline: "top",
x,
y,
lineHeight: 20,
fontWeight: 800,
text: `${i18n.t("regnode.mark")}${desc}`,
fill: "black",
},
name: "desc-text",
});
}
const args: any = cfg.args;
if (nodeDef.args && args && Object.keys(args).length > 0) {
const { str, line } = toBreakWord(`${i18n.t("regnode.args")}${JSON.stringify(args)}`, 34);
addShape("text", {
attrs: {
textBaseline: "top",
x,
y: y + 20,
w,
lineHeight: 20,
text: str,
fill: "black",
},
name: "args-text",
});
y += 20 * line;
}
const input: [] = cfg.input ? (cfg.input as []) : [];
if (nodeDef.input && input.length > 0) {
const { str, line } = toBreakWord(`${i18n.t("regnode.input")}${JSON.stringify(input)}`, 35);
if (cfg.highlightInput) {
addShape("rect", {
attrs: {
x: x - 2,
y: y + 17,
width: w - 6,
height: 18,
fill: "#0d1117",
radius: [r, r, r, r],
},
name: "input-text-bg",
});
}
addShape(
"text",
{
attrs: {
textBaseline: "top",
x,
y: y + 20,
lineHeight: 20,
text: str,
fill: cfg.highlightInput ? "white" : "black",
fontWeight: cfg.highlightInput ? "bolder" : undefined,
},
name: "input-text",
},
true
);
y += 20 * line;
}
const output: [] = cfg.output ? (cfg.output as []) : [];
if (nodeDef.output && output.length > 0) {
const { str, line } = toBreakWord(
`${i18n.t("regnode.output")}${JSON.stringify(output)}`,
35
);
if (cfg.highlightOutput) {
addShape("rect", {
attrs: {
x: x - 2,
y: y + 17,
width: w - 6,
height: 18,
fill: "#0d1117",
radius: [r, r, r, r],
},
name: "output-text-bg",
});
}
addShape(
"text",
{
attrs: {
textBaseline: "top",
x,
y: y + 20,
lineHeight: 20,
text: str,
fill: cfg.highlightOutput ? "white" : "black",
fontWeight: cfg.highlightOutput ? "bolder" : undefined,
},
name: "output-text",
},
true
);
y += 20 * line;
}
if (cfg.path) {
addShape("text", {
attrs: {
textBaseline: "top",
x,
y: y + 20,
lineHeight: 20,
text: `${i18n.t("regnode.subtree")}${cfg.path}`,
fill: "black",
},
name: "subtree-text",
});
y += 20;
}
addShape("rect", {
name: "drag-up",
attrs: {
x: 0,
y: 0,
width: w,
height: h / 2,
lineWidth: 2,
stroke: "#1668dc",
strokeOpacity: 0,
fill: "#1668dc",
fillOpacity: 0,
radius: [r, r, 0, 0],
},
draggable: true,
});
addShape("rect", {
name: "drag-down",
attrs: {
x: 0,
y: h / 2,
width: w,
height: h / 2,
lineWidth: 2,
stroke: "#1668dc",
strokeOpacity: 0,
fill: "#1668dc",
fillOpacity: 0,
radius: [0, 0, r, r],
},
draggable: true,
});
addShape("rect", {
name: "drag-right",
attrs: {
x: w / 2,
y: 0,
width: w / 2,
height: h,
lineWidth: 2,
stroke: "#1668dc",
strokeOpacity: 0,
fill: "#1668dc",
fillOpacity: 0,
radius: [0, r, r, 0],
},
draggable: true,
});
if (Array.isArray(cfg.children) && cfg.children.length > 0) {
addShape("marker", {
attrs: {
x: w,
y: h / 2,
r: 6,
symbol: G6.Marker.collapse,
stroke: "#666",
lineWidth: 1,
fill: "#fff",
},
name: "collapse-icon",
});
}
return shape;
},
},
"single-node"
);
+11
View File
@@ -0,0 +1,11 @@
import { useSetting } from "@/contexts/setting-context";
import { message, setGlobalHooks } from "@/misc/hooks";
import { FC } from "react";
export const Setup: FC = () => {
if (!message) {
useSetting().load();
}
setGlobalHooks();
return <div></div>;
};
+117
View File
@@ -0,0 +1,117 @@
import { useWorkspace } from "@/contexts/workspace-context";
import { Hotkey } from "@/misc/keys";
import Path from "@/misc/path";
import { SearchOutlined } from "@ant-design/icons";
import { Button, Flex, LayoutProps, Select } from "antd";
import React, { FC, useMemo } from "react";
import { Menu } from "./menu";
interface OptionType {
label: string | React.ReactNode;
value: string;
path: string;
}
export const TitleBar: FC<LayoutProps> = () => {
const workspace = {
allFiles: useWorkspace((state) => state.allFiles),
fileTree: useWorkspace((state) => state.fileTree),
isShowingSearch: useWorkspace((state) => state.isShowingSearch),
onShowingSearch: useWorkspace((state) => state.onShowingSearch),
open: useWorkspace((state) => state.open),
name: useWorkspace((state) => state.path),
};
const searchOptions = useMemo(() => {
const options: OptionType[] = [];
workspace.allFiles.forEach((path) => {
const value = Path.relative(workspace.fileTree!.path, path);
options.push({
label: (
<div>
{Path.basename(value)}
<span> </span>
<span style={{ color: "gray", fontSize: "12px" }}>{Path.dirname(value)}</span>
</div>
),
value: value,
path: path,
});
});
return options;
}, [workspace.allFiles, workspace.fileTree]);
return (
<Flex className="b3-titlebar" style={{ width: "100%", height: 35, alignItems: "center" }}>
<div
className="b3-drag-region"
style={{
display: "block",
height: "35px",
width: "100%",
left: 0,
top: 0,
position: "absolute",
}}
/>
<div style={{ width: "100%" }}>{process.platform !== "darwin" && <Menu />}</div>
<Flex style={{ width: "100%", alignItems: "center", justifyContent: "center" }}>
<Button
className="b3-no-drag-region"
onClick={() => {
workspace.onShowingSearch(true);
}}
style={{
width: "400px",
height: "25px",
border: "1px solid #30363d",
borderRadius: "6px",
padding: "0px",
cursor: "pointer",
color: "#7d8590",
boxShadow: "none",
}}
icon={<SearchOutlined style={{ color: "#7d8590" }} />}
>
{Path.basename(workspace.name)}
</Button>
{workspace.isShowingSearch && (
<Select
showSearch
autoFocus
defaultOpen
dropdownStyle={{
top: "5px",
paddingTop: "40px",
zIndex: 998,
display: "inline-block",
}}
onKeyDown={(e) => {
if (e.code === Hotkey.Escape) {
workspace.onShowingSearch(false);
}
}}
suffixIcon={<SearchOutlined />}
className="b3-search b3-no-drag-region"
style={{ width: "400px", top: "5px", position: "absolute", zIndex: 999 }}
placeholder=""
optionFilterProp="value"
onBlur={() => workspace.onShowingSearch(false)}
onChange={(_, option) => {
if (!(option instanceof Array)) {
workspace.onShowingSearch(false);
workspace.open(option.path);
}
}}
filterOption={(input, option) => (option?.value ?? "").includes(input)}
filterSort={(optionA, optionB) =>
(optionA?.value ?? "")
.toLowerCase()
.localeCompare((optionB?.value ?? "").toLowerCase())
}
options={searchOptions}
/>
)}
</Flex>
<div style={{ width: "100%" }}>{process.platform === "darwin" && <Menu />}</div>
</Flex>
);
};
+428
View File
@@ -0,0 +1,428 @@
import { useSetting } from "@/contexts/setting-context";
import { EditorStore, useWorkspace } from "@/contexts/workspace-context";
import { modal } from "@/misc/hooks";
import { Hotkey, isHotkeyPressed, setInputFocus, useHotkeys } from "@/misc/keys";
import Path from "@/misc/path";
import { app } from "@electron/remote";
import { Button, Flex, Layout, Space, Tabs, Tag, Tooltip } from "antd";
import { FC, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { BiAddToQueue } from "react-icons/bi";
import { FaExclamationTriangle, FaRegFolderOpen } from "react-icons/fa";
import { PiTreeStructureFill } from "react-icons/pi";
import useForceUpdate from "use-force-update";
import { useWindowSize } from "usehooks-ts";
import { Editor } from "./editor";
import { Explorer } from "./explorer";
import { Inspector } from "./inspector";
import { TitleBar } from "./titlebar";
const { Header, Content, Sider } = Layout;
export const Workspace: FC = () => {
const workspace = {
buildProject: useWorkspace((state) => state.buildProject),
close: useWorkspace((state) => state.close),
createProject: useWorkspace((state) => state.createProject),
edit: useWorkspace((state) => state.edit),
editing: useWorkspace((state) => state.editing),
editors: useWorkspace((state) => state.editors),
fileTree: useWorkspace((state) => state.fileTree),
find: useWorkspace((state) => state.find),
init: useWorkspace((state) => state.init),
isShowingSearch: useWorkspace((state) => state.isShowingSearch),
onShowingSearch: useWorkspace((state) => state.onShowingSearch),
openProject: useWorkspace((state) => state.openProject),
save: useWorkspace((state) => state.save),
};
const settings = {
recent: useSetting((state) => state.recent),
};
const [isShowingSave, setShowingSave] = useState(false);
const { t } = useTranslation();
const forceUpdate = useForceUpdate();
const isMac = process.platform === "darwin";
const { width = 0, height = 0 } = useWindowSize();
const keysRef = useHotkeys<HTMLDivElement>(
[Hotkey.Save, Hotkey.CloseEditor, Hotkey.Search, Hotkey.Build],
(event) => {
if (isHotkeyPressed(Hotkey.Save)) {
event.preventDefault();
workspace.save();
} else if (isHotkeyPressed(Hotkey.CloseEditor)) {
if (workspace.editing) {
event.preventDefault();
if (workspace.editing.unsave) {
showSaveDialog(workspace.editing);
} else {
workspace.close(workspace.editing.path);
}
}
} else if (isHotkeyPressed(Hotkey.Search)) {
event.preventDefault();
workspace.onShowingSearch(true);
} else if (isHotkeyPressed(Hotkey.Build)) {
event.preventDefault();
workspace.buildProject();
}
}
);
useEffect(() => {
if (!workspace.isShowingSearch && !isShowingSave) {
keysRef.current?.focus();
}
}, [workspace.isShowingSearch]);
const showSaveDialog = (editor: EditorStore) => {
if (isShowingSave) {
return;
}
setShowingSave(true);
const alert = modal.confirm({
centered: true,
content: (
<Flex vertical gap="middle">
<div>
<FaExclamationTriangle style={{ fontSize: "60px", color: "#FADB14" }} />
</div>
<div>{t("workspace.saveOnClose", { name: Path.basename(editor.path) })}</div>
</Flex>
),
footer: (
<Flex vertical gap="middle" style={{ paddingTop: "30px" }}>
<Flex vertical gap="6px">
<Button
type="primary"
onClick={() => {
editor.dispatch?.("save");
workspace.close(editor.path);
alert.destroy();
keysRef.current?.focus();
setShowingSave(false);
}}
>
{t("save")}
</Button>
<Button
danger
onClick={() => {
workspace.close(editor.path);
alert.destroy();
keysRef.current?.focus();
setShowingSave(false);
}}
>
{t("donotSave")}
</Button>
</Flex>
<Button
onClick={() => {
alert.destroy();
keysRef.current?.focus();
setShowingSave(false);
}}
>
{t("cancel")}
</Button>
</Flex>
),
});
};
const showSaveAllDialog = (unsaves: EditorStore[]) => {
if (isShowingSave) {
return;
}
setShowingSave(true);
const alert = modal.confirm({
centered: true,
content: (
<Flex vertical gap="middle">
<div>
<FaExclamationTriangle style={{ fontSize: "60px", color: "#FADB14" }} />
</div>
{unsaves.length == 1 && (
<div>{t("workspace.saveOnClose", { name: Path.basename(unsaves[0].path) })}</div>
)}
{unsaves.length > 1 && (
<>
<div>{t("workspace.saveAllOnClose", { count: unsaves.length })}</div>
<Flex vertical style={{ fontSize: "12px" }}>
{unsaves.map((editor) => (
<div>{Path.basename(editor.path)}</div>
))}
</Flex>
</>
)}
</Flex>
),
footer: (
<Flex vertical gap="middle" style={{ paddingTop: "30px" }}>
<Flex vertical gap="6px">
<Button
type="primary"
onClick={() => {
unsaves.forEach((editor) => editor.dispatch?.("save"));
alert.destroy();
window.close();
setShowingSave(false);
}}
>
{unsaves.length > 1 ? t("saveAll") : t("save")}
</Button>
<Button
danger
onClick={() => {
workspace.editors.length = 0;
alert.destroy();
window.close();
setShowingSave(false);
}}
>
{t("donotSave")}
</Button>
</Flex>
<Button
onClick={() => {
alert.destroy();
setShowingSave(false);
}}
>
{t("cancel")}
</Button>
</Flex>
),
});
};
window.onbeforeunload = (e) => {
const unsaves = workspace.editors.filter((editor) => editor.unsave);
if (unsaves.length) {
showSaveAllDialog(unsaves);
return false;
}
};
return (
<Layout
className="b3-workspace"
tabIndex={-1}
ref={keysRef}
style={{ width: width, height: height }}
>
<Header
style={{
padding: "0px",
height: "fit-content",
borderBottom: `1px solid var(--b3-color-border)`,
}}
>
<TitleBar />
</Header>
<Layout
hasSider
style={{ overflow: "hidden" }}
onFocus={(e) => {
setInputFocus(e.target);
}}
>
{workspace.fileTree && (
<Sider
width={300}
style={{
height: "100%",
borderRight: `1px solid var(--b3-color-border)`,
}}
>
<Explorer />
</Sider>
)}
<Content>
{!workspace.fileTree && (
<Flex vertical align="center" style={{ height: "100%" }}>
<Flex
vertical
style={{
fontSize: "15px",
width: "fit-content",
height: "100%",
paddingTop: "50px",
paddingBottom: "50px",
}}
>
<Flex
align="center"
gap="5px"
style={{ fontSize: "40px", fontWeight: "600", marginBottom: "10px" }}
>
<PiTreeStructureFill size="50px" />
<div>Behavior3 Editor</div>
</Flex>
<Flex vertical style={{ paddingLeft: "55px", paddingBottom: "15px" }}>
<div style={{ fontSize: "22px", fontWeight: "500" }}>{t("start")}</div>
<Flex
align="center"
gap="5px"
style={{
width: "fit-content",
fontWeight: "500",
cursor: "pointer",
color: "var(--ant-color-primary)",
}}
onClick={() => workspace.createProject()}
>
<BiAddToQueue size="20px" />
{t("createProject")}
</Flex>
<Flex
align="center"
gap="5px"
style={{
fontWeight: "500",
width: "fit-content",
cursor: "pointer",
color: "var(--ant-color-primary)",
}}
onClick={() => workspace.openProject()}
>
<FaRegFolderOpen size="19px" />
{t("openProject")}
</Flex>
</Flex>
<Flex vertical style={{ paddingLeft: "55px" }}>
<div style={{ fontSize: "22px", fontWeight: "500" }}>{t("recent")}</div>
<div style={{ overflow: "auto", height: "100%" }}>
{settings.recent.map((path) => {
const homedir = app.getPath("home");
return (
<div key={path}>
<span
onClick={() => workspace.openProject(path)}
style={{
color: "var(--ant-color-primary)",
fontWeight: "500",
cursor: "pointer",
marginRight: "15px",
}}
>
{Path.basename(path)}
</span>
<span>{Path.dirname(path).replace(homedir, "~")}</span>
</div>
);
})}
</div>
</Flex>
</Flex>
</Flex>
)}
{workspace.editors.length == 0 && (
<Flex vertical align="center" justify="center" style={{ height: "100%" }}>
<Flex
vertical
gap="10px"
style={{
color: "gray",
fontSize: "15px",
width: "fit-content",
paddingTop: "50px",
marginRight: "150px",
paddingBottom: "50px",
}}
>
{[
{ label: t("searchFile"), hotkeys: isMac ? "⌘ P" : "Ctrl + P" },
{ label: t("build"), hotkeys: isMac ? "⌘ B" : "Ctrl + B" },
{ label: t("insertNode"), hotkeys: "Enter" },
{ label: t("deleteNode"), hotkeys: "Backspace" },
].map((v) => (
<Flex
key={v.label}
gap="20px"
style={{
minWidth: "200px",
}}
>
<span style={{ width: "150px", textAlign: "end" }}>{v.label}</span>
<Space size={5}>
{v.hotkeys.split(" ").map((key, index) => (
<div key={index}>
{key === "+" && <div>+</div>}
{key !== "+" && (
<Tag style={{ color: "gray", fontSize: "14px", marginRight: 0 }}>
{key}
</Tag>
)}
</div>
))}
</Space>
</Flex>
))}
</Flex>
</Flex>
)}
{workspace.editors.length > 0 && (
<Tabs
hideAdd
type="editable-card"
activeKey={workspace.editing?.path}
onEdit={(activeKey, action) => {
if (action === "remove") {
const path = activeKey as string;
const editor = workspace.find(path);
if (editor && editor.unsave) {
showSaveDialog(editor);
} else {
workspace.close(path);
}
}
}}
onChange={(activeKey) => {
workspace.edit(activeKey);
}}
items={workspace.editors.map((v) => {
return {
label: (
<Tooltip
arrow={false}
placement="bottom"
mouseEnterDelay={1}
// mouseLeaveDelay={100}
color="#010409"
overlayStyle={{ userSelect: "none", WebkitUserSelect: "none" }}
autoAdjustOverflow={true}
overlayInnerStyle={{
width: "fit-content",
border: "1px solid var(--b3-color-border)",
borderRadius: "4px",
}}
title={<div style={{ width: "max-content" }}>{v.path}</div>}
>
{`${Path.basename(v.path)}${v.unsave ? "*" : ""}`}
</Tooltip>
),
key: v.path,
children: <Editor data={v} onUpdate={forceUpdate} />,
};
})}
/>
)}
</Content>
{workspace.editors.length > 0 && (
<Sider
className="b3-inspector-wrapper"
width={340}
style={{
height: "100%",
borderLeft: `1px solid var(--b3-color-border)`,
}}
>
<Inspector />
</Sider>
)}
</Layout>
</Layout>
);
};
+53
View File
@@ -0,0 +1,53 @@
import { readJson, writeJson } from "@/misc/json";
import { app } from "@electron/remote";
import { create } from "zustand";
import * as fs from "fs";
const settingPath = app.getPath("userData") + "/settings.json";
export type SettingModel = {
recent: string[];
};
export type SettingStore = {
recent: string[];
load: () => void;
save: () => void;
appendRecent: (path: string) => void;
removeRecent: (path: string) => void;
};
export const useSetting = create<SettingStore>((set, get) => ({
recent: [],
load: () => {
try {
if (fs.existsSync(settingPath)) {
const settings = readJson(settingPath) as SettingModel;
if (!(settings.recent instanceof Array)) {
settings.recent = [];
}
set({ recent: settings.recent });
}
} catch (error) {
console.error(error);
}
},
save: () => {
const settings = get();
writeJson(settingPath, {
recent: settings.recent,
} as SettingModel);
},
appendRecent: (path: string) => {
const recent = get().recent.filter((v) => v !== path);
recent.unshift(path);
set({ recent });
get().save();
},
removeRecent: (path: string) => {
const recent = get().recent.filter((v) => v !== path);
set({ recent });
get().save();
},
}));
+450
View File
@@ -0,0 +1,450 @@
import { NodeDef, NodeModel, TreeGraphData, TreeModel, unknownNodeDef } from "@/misc/b3type";
import * as b3util from "@/misc/b3util";
import { message } from "@/misc/hooks";
import i18n from "@/misc/i18n";
import { readJson } from "@/misc/json";
import Path from "@/misc/path";
import { Matrix, TreeGraph } from "@antv/g6";
import { BrowserWindow, dialog } from "@electron/remote";
import * as fs from "fs";
import React from "react";
import { create } from "zustand";
import { useSetting } from "./setting-context";
import { ipcRenderer } from "electron";
let buildDir: string | undefined;
interface BatchScript {
processTree?(tree: TreeModel): TreeModel;
processNode?(node: NodeModel, tree: TreeModel): NodeModel;
}
export type EditEvent =
| "save"
| "copy"
| "paste"
| "replace"
| "delete"
| "insert"
| "undo"
| "redo"
| "reload"
| "rename"
| "updateTree"
| "updateNode"
| "editSubtree"
| "saveAsSubtree";
export class EditorStore {
path: string;
data: TreeGraphData;
desc: string;
name: string;
autoId: number = 1;
dragSrcId?: string;
dragDstId?: string;
unsave: boolean = false;
historyStack: NodeModel[] = [];
historyIndex: number = 0;
selectedId?: string | null;
size = { width: 0, height: 0 };
graphMatrix?: Matrix;
graph!: TreeGraph;
dispatch?: (event: EditEvent, data?: unknown) => void;
constructor(path: string) {
this.path = path;
const file = readJson(path);
this.data = b3util.createTreeData(file.root);
this.desc = file.desc;
this.name = file.name || path.slice(0, -5);
this.autoId = b3util.refreshTreeDataId(this.data);
this.historyStack.push(b3util.createNode(this.data));
this.historyIndex = 0;
}
}
export type FileModel = {
path: string;
title: string;
icon?: React.ReactNode;
desc?: string;
isLeaf?: boolean;
children?: FileModel[];
editing?: boolean;
};
export type EditNode = {
data: NodeModel;
editable: boolean;
};
export type EditTree = {
data: TreeModel;
};
export type WorkspaceStore = {
init: (project: string) => void;
createProject: () => void;
openProject: (project?: string) => void;
batchProject: () => void;
buildProject: () => void;
workdir: string;
path: string;
allFiles: string[];
fileTree?: FileModel;
editors: EditorStore[];
editing?: EditorStore;
isShowingSearch: boolean;
onShowingSearch: (isShowingSearch: boolean) => void;
open: (path: string) => void;
edit: (path: string) => void;
close: (path: string) => void;
find: (path: string) => EditorStore | undefined;
save: () => void;
saveAs: () => void;
saveAll: () => void;
watch(): void;
loadTrees: () => void;
loadNodeDefs: () => void;
// edit node
editingNode?: EditNode | null;
onEditingNode: (node: EditNode) => void;
// edit tree
editingTree?: EditTree | null;
onEditingTree: (tree: EditTree | null) => void;
// node setting
nodeDefs: Map<string, NodeDef>;
getNodeDef: (name: string) => NodeDef;
hasNodeDef: (name: string) => boolean;
};
const loadFileTree = (workdir: string, filename: string) => {
const fullpath = fs.realpathSync(`${workdir}/${filename}`);
if (!fs.existsSync(fullpath)) {
return;
}
const stat = fs.statSync(fullpath);
if (!(stat.isDirectory() || fullpath.endsWith(".json"))) {
return;
}
const data: FileModel = {
path: fullpath.replaceAll(Path.sep, "/"),
title: Path.basename(filename),
};
if (stat.isDirectory()) {
data.children = [];
const files = fs.readdirSync(data.path);
files.forEach((v) => {
const child = loadFileTree(workdir, `${filename}/${v}`);
if (child) {
data.children?.push(child);
}
});
data.children.sort((a, b) => {
if ((a.children && b.children) || (!a.children && !b.children)) {
return a.title.localeCompare(b.title);
} else {
return a.children ? -1 : 1;
}
});
} else {
data.isLeaf = true;
}
return data;
};
const saveFile = (editor?: EditorStore) => {
if (editor?.unsave) {
editor.dispatch?.("save");
}
};
export const useWorkspace = create<WorkspaceStore>((set, get) => ({
allFiles: [],
fileTree: undefined,
editors: [],
workdir: "",
path: "",
init: (path) => {
const workspace = get();
if (!workspace.workdir) {
try {
workspace.workdir = Path.dirname(path).replaceAll(Path.sep, "/");
workspace.path = path;
workspace.loadTrees();
workspace.loadNodeDefs();
workspace.watch();
useSetting.getState().appendRecent(path);
} catch (error) {
console.error(error);
useSetting.getState().removeRecent(path);
message.error(`load workspace error: ${path}`);
}
}
},
createProject: () => {
const path = dialog.showSaveDialogSync({
properties: ["showOverwriteConfirmation", "createDirectory"],
filters: [{ name: "Behavior3 Workspace", extensions: ["b3-workspace"] }],
});
if (path) {
const workspace = get();
b3util.createProject(path);
workspace.init(path);
}
},
openProject: (project?: string) => {
if (project) {
ipcRenderer.invoke("open-win", project);
} else {
const win = BrowserWindow.getFocusedWindow();
if (win) {
const path = dialog.showOpenDialogSync(win, {
filters: [{ name: "workspace", extensions: ["b3-workspace"] }],
});
if (path?.length) {
ipcRenderer.invoke("open-win", path[0]);
}
}
}
},
buildProject: () => {
const workspace = get();
if (!buildDir && workspace.path) {
buildDir = dialog.showOpenDialogSync({
properties: ["openDirectory", "createDirectory"],
})?.[0];
if (buildDir) {
for (const editor of workspace.editors) {
editor.dispatch?.("save");
}
try {
for (const path of workspace.allFiles) {
const buildpath = buildDir + "/" + Path.relative(workspace.workdir, path);
console.log("build:", buildpath);
const treeModel = b3util.createBuildData(path);
fs.mkdirSync(Path.dirname(buildpath), { recursive: true });
fs.writeFileSync(buildpath, JSON.stringify(treeModel, null, 2));
}
message.success(i18n.t("buildCompleted"));
} catch (error) {
console.error(error);
}
}
}
},
batchProject: () => {
const workspace = get();
const scriptPath = dialog.showOpenDialogSync({
properties: ["openFile"],
defaultPath: workspace.workdir,
filters: [{ name: "Javascript", extensions: ["js"] }],
})?.[0];
if (scriptPath) {
try {
console.log("run script", scriptPath);
const str = fs.readFileSync(scriptPath, "utf8");
const batch = eval(str) as BatchScript;
workspace.allFiles.forEach((path) => {
const treeStr = fs.readFileSync(path, "utf8");
let tree: TreeModel | undefined = JSON.parse(treeStr);
if (batch.processTree && tree) {
tree = batch.processTree(tree);
}
if (tree && batch.processNode) {
const processNode = (node: NodeModel) => {
batch.processNode?.(node, tree!);
node.children?.forEach((child) => processNode(child));
};
processNode(tree.root);
}
if (tree) {
fs.writeFileSync(path, JSON.stringify(tree));
}
});
} catch (error) {
console.error(error);
}
}
},
isShowingSearch: false,
onShowingSearch: (isShowingSearch) => {
set({ isShowingSearch });
},
open: (path) => {
const workspace = get();
let editor = workspace.editors.find((v) => v.path === path);
if (!editor) {
try {
editor = new EditorStore(path);
workspace.editors.push(editor);
set({ editors: workspace.editors });
workspace.edit(editor.path);
} catch (error) {
console.error(error);
message.error(`invalid file: ${path}`);
}
} else if (workspace.editing !== editor) {
workspace.edit(editor.path);
}
},
edit: (path) => {
const workspace = get();
const editor = workspace.editors.find((v) => v.path === path);
set({ editing: editor, editingNode: null });
if (editor) {
set({
editingTree: {
data: {
name: editor.name,
desc: editor.desc,
root: null!,
},
},
});
} else {
set({ editingNode: null, editingTree: null });
}
},
close: (path) => {
const workspace = get();
const idx = workspace.editors.findIndex((v) => v.path === path);
const editors = workspace.editors.filter((v) => v.path !== path);
let editting = workspace.editing;
if (editors.length && path === editting?.path) {
editting = editors[idx === editors.length ? idx - 1 : idx];
set({
editingTree: {
data: {
name: editting.name,
desc: editting.desc,
root: null!,
},
},
});
}
if (editors.length === 0) {
editting = undefined;
set({ editingNode: undefined });
}
set({ editing: editting, editors: editors });
},
find: (path) => {
const workspace = get();
return workspace.editors.find((v) => v.path === path);
},
save: () => {
const workspace = get();
saveFile(workspace.editing);
},
saveAs: () => {},
saveAll: () => {
const workspace = get();
for (const editor of workspace.editors) {
saveFile(editor);
}
},
watch: () => {
try {
const workspace = get();
let hasEvent = false;
fs.watch(workspace.workdir, { recursive: true }, (event, filename) => {
console.log("fileanme", event, filename);
if (event === "rename") {
if (!hasEvent) {
setTimeout(() => {
workspace.loadTrees();
hasEvent = false;
}, 200);
hasEvent = true;
}
}
if (event === "change" && filename === "node-config.b3-setting") {
workspace.loadNodeDefs();
}
});
} catch (e) {
console.error(e);
}
},
loadTrees: () => {
const workspace = get();
const data = loadFileTree(workspace.workdir, ".")!;
data.title = Path.basename(workspace.workdir);
set({ fileTree: data });
const allFiles: string[] = [];
const collect = (fileNode?: FileModel) => {
if (fileNode?.isLeaf) {
allFiles.push(fileNode.path);
}
fileNode?.children?.forEach((child) => collect(child));
};
collect(data);
set({ allFiles });
},
loadNodeDefs: () => {
const workspace = get();
const nodeDefData = readJson(`${workspace.workdir}/node-config.b3-setting`) as NodeDef[];
for (const v of nodeDefData) {
workspace.nodeDefs.set(v.name, v);
}
set({ nodeDefs: workspace.nodeDefs });
workspace.editing?.dispatch?.("reload");
},
// node edit
onEditingNode: (node) => {
set({ editingNode: node });
},
// tree edit
onEditingTree: (tree) => {
set({ editingTree: tree });
},
// node def
nodeDefs: new Map(),
getNodeDef: (name) => {
const workspace = get();
unknownNodeDef.desc = i18n.t("node.unknown.desc");
return workspace.nodeDefs.get(name) || unknownNodeDef;
},
hasNodeDef: (name) => {
const workspace = get();
return workspace.nodeDefs.has(name);
},
}));
+159
View File
@@ -0,0 +1,159 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: only dark;
background-color: #0d1117;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
--b3-color-border: #30363d;
}
body {
margin: 0;
}
.b3-drag-region {
-webkit-app-region: drag;
}
.b3-no-drag-region {
-webkit-app-region: no-drag;
}
.b3-search .ant-select-selector {
border: 1px solid var(--b3-color-border) !important;
}
.b3-search .ant-select-selector:hover,
.b3-search .ant-select-selector:focus-within {
border: 1px solid var(--ant-color-primary) !important;
}
.ant-input-number {
width: 100%;
}
.ant-tabs-nav {
background-color: #010409;
}
.ant-tabs,
.ant-tabs-content-holder,
.ant-tabs-content,
.ant-tabs-tabpane,
.b3-editor {
height: 100%;
}
.ant-tabs-tab {
border-radius: 0 !important;
margin-left: 0 !important;
border-left-width: 0 !important;
border-top: 2px solid #010409 !important;
}
.ant-tabs-tab.ant-tabs-tab-active {
border-top: 2px solid #f78166 !important;
border-bottom: 1px solid #0d1117 !important;
background-color: #0d1117 !important;
}
.ant-tree,
.ant-tree-treenode-draggable {
cursor: pointer !important;
}
.ant-tree .ant-tree-node-content-wrapper {
padding-left: 0 !important;
}
.ant-tree-switcher.ant-tree-switcher-noop,
.ant-tree-switcher.ant-tree-switcher_open,
.ant-tree-switcher.ant-tree-switcher_close {
width: 20px !important;
text-align: end !important;
background-color: unset !important;
}
.ant-tree-indent .ant-tree-indent-unit {
width: 12px !important;
}
.b3-inspector .ant-form-item {
margin-bottom: 16px;
}
.b3-inspector .ant-form-item-label {
min-width: 100px;
}
:focus {
outline: none;
}
.ant-form-item .ant-form-item-explain {
font-size: small;
}
.ant-divider-horizontal.ant-divider-with-text {
margin-bottom: 0px;
}
.ant-modal-root,
.b3-workspace {
user-select: none;
-webkit-user-select: none;
}
.ant-modal {
margin-bottom: 100px;
width: 270px !important;
}
.ant-modal-content {
border-radius: 6px !important;
padding-left: 20px !important;
padding-right: 20px !important;
}
.ant-modal-confirm-body .anticon-exclamation-circle {
display: none;
}
.ant-modal-confirm-paragraph {
display: contents !important;
text-align: center !important;
font-weight: 600;
}
.ant-modal-confirm-btns {
margin-top: 30px !important;
display: flex;
}
.ant-modal-confirm-btns .ant-btn {
width: 100%;
}
.b3-app-menu .ant-dropdown-trigger,
.b3-app-menu .ant-dropdown-menu-item,
.b3-app-menu .ant-dropdown-menu-submenu-title {
height: 23px;
padding-left: 10px !important;
padding-right: 10px !important;
font-size: 13px !important;
font-weight: 500 !important;
}
.b3-app-menu .ant-dropdown-menu-submenu-title {
display: flex;
align-items: center;
}
+20
View File
@@ -0,0 +1,20 @@
import { App, ConfigProvider } from "antd";
import React from "react";
import ReactDOM from "react-dom/client";
import { Setup } from "./components/setup";
import { Workspace } from "./components/workspace";
import "./misc/i18n";
import { themeConfig } from "./misc/theme";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<ConfigProvider theme={themeConfig}>
<App>
<Setup />
<Workspace />
</App>
</ConfigProvider>
</React.StrictMode>
);
postMessage({ payload: "removeLoading" }, "*");
+67
View File
@@ -0,0 +1,67 @@
import { TreeGraphData as G6TreeGraphData } from "@antv/g6";
export interface NodeArgOption {
name: string;
value: string | number;
}
export interface NodeArg {
name: string;
type: string;
desc: string;
default?: string;
options?: NodeArgOption[];
}
export interface NodeDef {
name: string;
type?: string;
desc?: string;
args?: NodeArg[];
input?: string[];
output?: string[];
doc?: string;
color?: string;
icon?: string;
}
export interface NodeModel {
id: number;
name: string;
desc?: string;
args?: { [key: string]: any };
input?: string[];
output?: string[];
children?: NodeModel[];
debug?: boolean;
path?: string;
}
export interface TreeModel {
name: string;
desc?: string;
root: NodeModel;
}
export interface TreeGraphData extends G6TreeGraphData {
name: string;
desc?: string;
args?: { [key: string]: any };
input?: string[];
output?: string[];
children?: TreeGraphData[];
def: NodeDef;
debug?: boolean;
parent?: string;
path?: string;
lastModified?: number;
size?: number[];
highlightInput?: boolean;
highlightOutput?: boolean;
}
export const unknownNodeDef: NodeDef = {
name: "unknown",
desc: "新建节点",
type: "Action",
};
+439
View File
@@ -0,0 +1,439 @@
import { useWorkspace } from "@/contexts/workspace-context";
import { NodeModel, TreeGraphData, TreeModel } from "@/misc/b3type";
import * as fs from "fs";
import { message } from "./hooks";
import Path from "./path";
import i18n from "./i18n";
export const isSubtreeRoot = (data: TreeGraphData) => {
return data.path && data.id.toString() !== "1";
};
export const isSubtreeUpdated = (data: TreeGraphData) => {
if (data.path) {
try {
const subtreePath = useWorkspace.getState().workdir + "/" + data.path;
if (fs.statSync(subtreePath).mtimeMs !== data.lastModified) {
return true;
}
} catch (error) {
return true;
}
}
if (data.children) {
for (const child of data.children) {
if (isSubtreeUpdated(child)) {
return true;
}
}
}
return false;
};
export const isNodeEqual = (node1: NodeModel, node2: NodeModel) => {
if (
node1.name === node2.name &&
node1.desc === node2.desc &&
node1.path === node2.path &&
node1.debug === node2.debug
) {
const def = useWorkspace.getState().getNodeDef(node1.name);
for (const arg of def.args ?? []) {
if (node1.args?.[arg.name] !== node2.args?.[arg.name]) {
return false;
}
}
if (def.input?.length) {
for (let i = 0; i < def.input.length; i++) {
if (node1.input?.[i] !== node2.input?.[i]) {
return false;
}
}
}
if (def.output?.length) {
for (let i = 0; i < def.output.length; i++) {
if (node1.output?.[i] !== node2.output?.[i]) {
return false;
}
}
}
return true;
}
return false;
};
export const copyFromNode = (data: TreeGraphData, node: NodeModel) => {
data.name = node.name;
data.debug = node.debug;
data.desc = node.desc;
data.path = node.path;
data.args = node.args;
data.input = node.input;
data.output = node.output;
};
const parsingStack: string[] = [];
export const createNode = (data: TreeGraphData, includeChildren: boolean = true) => {
const node: NodeModel = {
id: Number(data.id),
name: data.name,
desc: data.desc,
path: data.path,
debug: data.debug,
};
if (data.input) {
node.input = [];
for (const v of data.input) {
node.input.push(v ?? "");
}
}
if (data.output) {
node.output = [];
for (const v of data.output) {
node.output.push(v ?? "");
}
}
if (data.args) {
node.args = {};
for (const k in data.args) {
const v = data.args[k];
if (v !== null && v !== undefined) {
node.args[k] = v;
}
}
}
if (data.children && !isSubtreeRoot(data) && includeChildren) {
node.children = [];
for (const child of data.children) {
node.children.push(createNode(child));
}
}
return node;
};
export const refreshTreeDataId = (data: TreeGraphData, id?: number) => {
if (!id) {
id = 1;
}
data.id = (id++).toString();
if (data.children) {
data.children.forEach((child) => {
child.parent = data.id;
id = refreshTreeDataId(child, id);
});
}
return id;
};
export const calcTreeDataSize = (data: TreeGraphData) => {
let height = 40 + 2;
const updateHeight = (obj: any) => {
if (Array.isArray(obj) || (obj && Object.keys(obj).length > 0)) {
const { str, line } = toBreakWord(`参数:${JSON.stringify(obj)}`, 35);
height += 20 * line;
}
};
if (data.path) {
height += 20;
}
updateHeight(data.args);
updateHeight(data.input);
updateHeight(data.output);
return [200, height];
};
export const createTreeData = (node: NodeModel, parent?: string) => {
const workspace = useWorkspace.getState();
let treeData: TreeGraphData = {
id: node.id.toFixed(),
name: node.name,
desc: node.desc,
args: node.args,
input: node.input,
output: node.output,
debug: node.debug,
def: workspace.getNodeDef(node.name),
parent: parent,
};
treeData.size = calcTreeDataSize(treeData);
if (!parent) {
parsingStack.length = 0;
}
if (node.children) {
treeData.children = [];
node.children.forEach((child) => {
treeData.children!.push(createTreeData(child, treeData.id));
});
} else if (node.path) {
if (parsingStack.indexOf(node.path) >= 0) {
treeData.path = node.path;
treeData.size = calcTreeDataSize(treeData);
message.error(`循环引用节点:${node.path}`, 4);
return treeData;
}
parsingStack.push(node.path);
try {
const subtreePath = workspace.workdir + "/" + node.path;
const str = fs.readFileSync(subtreePath, "utf8");
treeData = createTreeData(JSON.parse(str).root, treeData.id);
treeData.lastModified = fs.statSync(subtreePath).mtimeMs;
treeData.path = node.path;
treeData.debug = node.debug;
treeData.size = calcTreeDataSize(treeData);
} catch (error) {
message.error(`解析子树失败:${node.path}`);
console.log("parse subtree:", error);
}
parsingStack.pop();
}
calcTreeDataSize(treeData);
return treeData;
};
export const createBuildData = (path: string) => {
try {
const str = fs.readFileSync(path, "utf8");
const treeModel = JSON.parse(str);
const data = createTreeData(treeModel.root);
refreshTreeDataId(data);
treeModel.root = createFileData(data, true);
return treeModel as NodeModel;
} catch (error) {
console.log("build error:", path, error);
}
return null;
};
export const createFileData = (data: TreeGraphData, includeSubtree?: boolean) => {
const nodeData: NodeModel = {
id: Number(data.id),
name: data.name,
desc: data.desc || undefined,
args: data.args || undefined,
input: data.input || undefined,
output: data.output || undefined,
debug: data.debug || undefined,
path: data.path || undefined,
};
if (data.children?.length && (includeSubtree || !isSubtreeRoot(data))) {
nodeData.children = [];
data.children.forEach((child) => {
nodeData.children!.push(createFileData(child, includeSubtree));
});
}
return nodeData;
};
export const createNewTree = (filename: string) => {
const tree: TreeModel = {
name: Path.basenameWithoutExt(filename),
root: {
id: 1,
name: "Sequence",
},
};
return tree;
};
export const toBreakWord = (str: string, len: number, char = "\n") => {
let strTemp = "";
let line = 1;
if (str.length <= len) {
return { str, line };
}
while (str.length > len) {
strTemp += str.slice(0, len) + char;
str = str.slice(len, str.length);
line++;
}
strTemp += str;
return {
str: strTemp,
line,
};
};
export const createProject = (path: string) => {
fs.writeFileSync(
Path.dirname(path) + "/node-config.b3-setting",
JSON.stringify(
[
{
name: "AlwaysFail",
type: "Decorator",
desc: i18n.t("node.alwaysFail.desc"),
doc: i18n.t("node.alwaysFail.doc"),
},
{
name: "AlwaysSuccess",
type: "Decorator",
desc: i18n.t("node.alwaysSuccess.desc"),
doc: i18n.t("node.alwaysSuccess.doc"),
},
{
name: "Check",
type: "Condition",
desc: i18n.t("node.check.desc"),
args: [
{
name: "value",
type: "code",
desc: i18n.t("node.check.arg1.desc"),
},
],
doc: i18n.t("node.check.doc"),
},
{
name: "Clear",
type: "Action",
desc: i18n.t("node.clear.desc"),
output: [i18n.t("node.clear.output1")],
},
{
name: "ForEach",
type: "Composite",
desc: i18n.t("node.forEach.desc"),
input: [i18n.t("node.forEach.input1")],
output: [i18n.t("node.forEach.output1")],
doc: i18n.t("node.forEach.doc"),
},
{
name: "GetTime",
type: "Action",
desc: i18n.t("node.getTime.desc"),
output: [i18n.t("node.getTime.output1")],
},
{
name: "IsNull",
type: "Condition",
desc: i18n.t("node.isNull.desc"),
input: [i18n.t("node.isNull.input1")],
},
{
name: "Log",
type: "Action",
desc: i18n.t("node.log.desc"),
args: [
{
name: "message",
type: "string",
desc: i18n.t("node.log.arg1.desc"),
},
],
},
{
name: "Loop",
type: "Composite",
desc: i18n.t("node.loop.desc"),
args: [
{
name: "count",
type: "int?",
desc: i18n.t("node.loop.arg1.desc"),
},
],
input: [i18n.t("node.loop.input1") + "?"],
},
{
name: "Not",
type: "Decorator",
desc: i18n.t("node.not.desc"),
doc: i18n.t("node.not.doc"),
},
{
name: "NotNull",
type: "Condition",
desc: i18n.t("node.notNull.desc"),
input: [i18n.t("node.notNull.input")],
},
{
name: "Once",
type: "Composite",
desc: i18n.t("node.once.desc"),
doc: i18n.t("node.once.doc"),
},
{
name: "Parallel",
type: "Composite",
desc: i18n.t("node.parallel.desc"),
doc: i18n.t("node.parallel.doc"),
},
{
name: "Selector",
type: "Composite",
desc: i18n.t("node.selector.desc"),
doc: i18n.t("node.selector.doc"),
},
{
name: "Sequence",
type: "Composite",
desc: i18n.t("node.sequence.desc"),
doc: i18n.t("node.sequence.doc"),
},
{
name: "Wait",
type: "Action",
desc: i18n.t("node.wait.desc"),
args: [
{
name: "time",
type: "float",
desc: i18n.t("node.wait.arg1.desc"),
},
],
},
],
null,
2
)
);
fs.writeFileSync(
Path.dirname(path) + "/example.json",
JSON.stringify(
{
name: "example",
root: {
id: 1,
name: "Sequence",
children: [
{
id: 2,
name: "Log",
args: {
str: "hello",
},
},
{
id: 3,
name: "Wait",
args: {
time: 1,
},
},
],
},
},
null,
2
)
);
fs.writeFileSync(
path,
JSON.stringify(
{
nodeConf: "node-config.b3-setting",
metadata: [],
},
null,
2
)
);
};
+17
View File
@@ -0,0 +1,17 @@
import { App } from "antd";
import type { MessageInstance } from "antd/es/message/interface";
import type { ModalStaticFunctions } from "antd/es/modal/confirm";
import type { NotificationInstance } from "antd/es/notification/interface";
let message: MessageInstance;
let notification: NotificationInstance;
let modal: Omit<ModalStaticFunctions, "warn">;
const setGlobalHooks = () => {
const staticFunction = App.useApp();
message = staticFunction.message;
modal = staticFunction.modal;
notification = staticFunction.notification;
};
export { message, modal, notification, setGlobalHooks };
+45
View File
@@ -0,0 +1,45 @@
import dayjs from "dayjs";
import i18n from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import HttpApi from "i18next-http-backend";
import { initReactI18next } from "react-i18next";
declare module "i18next" {
interface CustomTypeOptions {
returnNull: false;
}
}
i18n
.use(LanguageDetector)
.use(initReactI18next)
.use(HttpApi)
.init(
{
debug: import.meta.env.NODE_ENV === "development",
returnNull: false,
fallbackLng: "en",
interpolation: {
escapeValue: false,
},
react: {
useSuspense: false,
},
load: "languageOnly",
detection: {
caches: ["localStorage", "sessionStorage", "cookie"],
},
backend: {
loadPath: `locales/{{lng}}.json`,
},
},
function (err, t) {
dayjs.locale(i18n.language);
}
);
i18n.on("languageChanged", (lng) => {
dayjs.locale(lng);
});
export default i18n;
+11
View File
@@ -0,0 +1,11 @@
import * as fs from "fs";
export const readJson = (path: string) => {
const str = fs.readFileSync(path, "utf-8");
return JSON.parse(str);
};
export const writeJson = (path: string, data: any) => {
const str = JSON.stringify(data, undefined, 2);
fs.writeFileSync(path, str, "utf-8");
};
+105
View File
@@ -0,0 +1,105 @@
import { BrowserWindow } from "@electron/remote";
import { MutableRefObject } from "react";
import {
HotkeyCallback,
Keys,
isHotkeyPressed as _isHotkeyPressed,
useHotkeys as _useHotkeys,
} from "react-hotkeys-hook";
import { OptionsOrDependencyArray, RefType } from "react-hotkeys-hook/dist/types";
import { Key } from "ts-key-enum";
export const isMacos = process.platform === "darwin";
let forceTarget: HTMLElement | undefined;
const hotkey = (key: string) => {
if (key.indexOf("ctrl") >= 0 && isMacos) {
key = key.replace("ctrl", Key.Meta);
}
return key.toLowerCase();
};
export const isHotkeyPressed = (key: string) => {
if (!key) {
return false;
} else if (key.indexOf("ctrl") >= 0 && isMacos) {
key = key.replace("ctrl", Key.Meta);
}
return _isHotkeyPressed(key.toLowerCase(), "+");
};
export function useHotkeys<T extends HTMLElement>(
keys: Keys,
callback: HotkeyCallback,
options?: OptionsOrDependencyArray,
dependencies?: OptionsOrDependencyArray
): MutableRefObject<RefType<T>> {
if (keys instanceof Array) {
keys = keys.filter((v) => !!v);
}
return _useHotkeys(keys, callback, options, dependencies);
}
export const Hotkey = {
Backspace: Key.Backspace,
Build: hotkey("ctrl+b"),
CloseEditor: hotkey("ctrl+w"),
Copy: hotkey("ctrl+c"),
Delete: Key.Delete,
Duplicate: hotkey("ctrl+d"),
Enter: Key.Enter,
Escape: Key.Escape,
F2: Key.F2,
Insert: Key.Insert,
MacDelete: isMacos ? hotkey("ctrl+backspace") : "",
Paste: hotkey("ctrl+v"),
Redo: isMacos ? hotkey("shift+ctrl+z") : hotkey("ctrl+y"),
Replace: hotkey("shift+ctrl+v"),
Save: hotkey("ctrl+s"),
Search: hotkey("ctrl+p"),
Undo: hotkey("ctrl+z"),
};
export const setInputFocus = (target: HTMLElement) => {
forceTarget = target;
};
export const sendInputEvent = (key: string) => {
const webContents = BrowserWindow.getFocusedWindow()?.webContents;
if (webContents) {
let keyCode: string | undefined;
const modifiers: Electron.InputEvent["modifiers"] = [];
key.split("+").forEach((v) => {
if (v.indexOf("ctrl") >= 0) {
modifiers.push("ctrl");
} else if (v.indexOf("shift") >= 0) {
modifiers.push("shift");
} else if (v.indexOf("alt") >= 0) {
modifiers.push("alt");
} else if (v.indexOf("meta") >= 0) {
modifiers.push("meta");
} else {
keyCode = v;
}
});
if (!keyCode) {
return;
}
forceTarget?.focus();
modifiers.forEach((mod) => {
webContents.sendInputEvent({
type: "keyDown",
keyCode: mod,
});
});
webContents.sendInputEvent({ type: "keyDown", keyCode: keyCode, modifiers: modifiers });
webContents.sendInputEvent({ type: "keyUp", keyCode: keyCode, modifiers: modifiers });
modifiers.forEach((mod) => {
webContents.sendInputEvent({
type: "keyUp",
keyCode: mod,
});
});
}
};
+16
View File
@@ -0,0 +1,16 @@
import path from "path";
const Path = path;
declare module "path" {
interface PlatformPath {
basenameWithoutExt(path: string): string;
}
}
// eslint-disable-next-line @typescript-eslint/no-shadow
path.basenameWithoutExt = (path: string) => {
return Path.basename(path, Path.extname(path));
};
export default Path;
+31
View File
@@ -0,0 +1,31 @@
import { ThemeConfig, theme } from "antd";
import "../index.scss";
export const themeConfig: ThemeConfig = {
cssVar: true,
algorithm: theme.darkAlgorithm,
token: {
colorBgBase: "#0d1117",
colorBgContainer: "#0d1117",
colorBgElevated: "#161b22",
colorBorderSecondary: "#30363d",
borderRadius: 4,
},
components: {
Tree: {
// directoryNodeSelectedBg: "#010409",
borderRadius: 0,
colorBgContainer: "#010409",
},
Tabs: {
horizontalMargin: "0",
},
Layout: {
headerBg: "#0d1117",
siderBg: "#010409",
},
Dropdown: {
motionDurationMid: "0.1s",
},
},
};
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+22 -18
View File
@@ -1,21 +1,25 @@
{
"compilerOptions": {
"outDir": "./dist/",
"sourceMap": true,
"esModuleInterop": true,
"noImplicitAny": true,
"module": "commonjs",
"target": "es6",
"jsx": "react"
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
}
},
"include": [
"./common/**/*",
"./main-process/**/*",
"./render-process/**/*",
"./fs-tree-view/**/*",
],
"exclude": [
"node_modules",
"dist"
]
}
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts", "package.json", "electron"]
}
+59
View File
@@ -0,0 +1,59 @@
import { rmSync } from "node:fs";
import path from "node:path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import electron from "vite-electron-plugin";
import { customStart, loadViteEnv } from "vite-electron-plugin/plugin";
import renderer from "vite-plugin-electron-renderer";
import pkg from "./package.json";
// https://vitejs.dev/config/
export default defineConfig(({ command }) => {
rmSync("dist-electron", { recursive: true, force: true });
const sourcemap = true; //command === "serve" || !!process.env.VSCODE_DEBUG;
return {
build: {
sourcemap: true,
},
resolve: {
alias: {
"@": path.join(__dirname, "src"),
},
},
plugins: [
react(),
electron({
include: ["electron"],
transformOptions: {
sourcemap,
},
plugins: [
...(!!process.env.VSCODE_DEBUG
? [
// Will start Electron via VSCode Debug
customStart(() =>
console.log(/* For `.vscode/.debug.script.mjs` */ "[startup] Electron App")
),
]
: []),
// Allow use `import.meta.env.VITE_SOME_KEY` in Electron-Main
loadViteEnv(),
],
}),
// Use Node.js API in the Renderer-process
renderer(),
],
server: !!process.env.VSCODE_DEBUG
? (() => {
const url = new URL(pkg.debug.env.VITE_DEV_SERVER_URL);
return {
host: url.hostname,
port: +url.port,
};
})()
: undefined,
clearScreen: false,
};
});
-52
View File
@@ -1,52 +0,0 @@
const CopyPlugin = require("copy-webpack-plugin");
module.exports = {
mode: "development",
entry: {
"bundle": ["./render-process/index.tsx"],
"main": ["./main-process/MainProcess.tsx"]
},
output: {
filename: "[name].js",
path: __dirname + "/dist"
},
devtool: "source-map",
resolve: {
extensions: [".ts", ".tsx", ".js", ".json"]
},
module: {
rules: [
{ test: /\.tsx?$/, loader: "ts-loader" },
{ test: /\.(jpe?g|png|gif|svg)$/i, loader: "file-loader" },
{ enforce: "pre", test: /\.js$/, loader: "source-map-loader" },
{
test: /\.less$/,
include: /node_modules/,
use: ['style-loader',{ loader: 'css-loader' },{ loader: 'less-loader' }]
},
{
test: /\.css$/,
use: ['style-loader', { loader: 'css-loader' }]
},
{//https://github.com/ashtuchkin/iconv-lite/issues/205
test: /node_modules[\/\\](iconv-lite)[\/\\].+/,
resolve: {
aliasFields: ['main']
}
},
]
},
plugins: [
],
target: "electron-renderer",
externals: [{
'electron-reload': 'require("electron-reload")'
}]
};