diff --git a/electron/main/index.ts b/electron/main/index.ts index 2dcdb3d..c8af91b 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -5,9 +5,11 @@ import os from "node:os"; import path from "node:path"; import { argv } from "node:process"; import { fileURLToPath } from "node:url"; +import { type WorkspaceModel } from "../../src/contexts/workspace-context"; import { VERSION, type FileVarDecl } from "../../src/misc/b3type"; import * as b3util from "../../src/misc/b3util"; import Path from "../../src/misc/path"; +import { readJson } from "../../src/misc/util"; const require = createRequire(import.meta.url); const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -19,13 +21,13 @@ let buildHelp: boolean = false; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; - if (arg === "--project") { + if (arg === "-p") { buildProject = argv[i + 1]; i++; - } else if (arg === "--build") { + } else if (arg === "-o") { buildOutput = argv[i + 1]; i++; - } else if (arg === "--help") { + } else if (arg === "-h" || arg === "-v") { buildHelp = true; } } @@ -33,9 +35,9 @@ for (let i = 0; i < argv.length; i++) { const printHelp = () => { console.log(`Usage: Behavior3 Editor ${VERSION} [options]`); console.log("Options:"); - console.log(" --project Set the project path"); - console.log(" --build Set the build output path"); - console.log(" --help Print this help"); + console.log(" -p Set the project path"); + console.log(" -o Set the build output path"); + console.log(" -h -v Print this help"); }; if (buildOutput || buildProject || buildHelp) { @@ -61,29 +63,45 @@ if (buildOutput || buildProject || buildHelp) { b3util.initWorkdir(workdir, (msg) => { console.error(`${msg}`); }); + const settings = readJson(project).settings; + let buildScript: b3util.BuildScript | undefined; + if (settings.checkExpr) { + b3util.setCheckExpr(true); + } + if (settings.buildScript) { + const scriptPath = workdir + "/" + settings.buildScript; + try { + buildScript = eval(fs.readFileSync(scriptPath, "utf8")); + } catch (error) { + console.error(`'${scriptPath}' is not a valid build script`); + } + } for (const path of Path.ls(Path.dirname(project), true)) { if (path.endsWith(".json")) { const buildpath = buildDir + "/" + path.substring(workdir.length + 1); - const treeModel = b3util.createBuildData(path); - if (!treeModel) { + let tree = b3util.createBuildData(path); + if (buildScript) { + tree = b3util.processBatch(tree, path, buildScript); + } + if (!tree) { continue; } - if (treeModel.export === false) { + if (tree.export === false) { console.log("skip:", buildpath); continue; } console.log("build:", buildpath); const declare: FileVarDecl = { - import: treeModel.import.map((v) => ({ path: v, vars: [], depends: [] })), - declvar: treeModel.declvar.map((v) => ({ name: v.name, desc: v.desc })), + import: tree.import.map((v) => ({ path: v, vars: [], depends: [] })), + declvar: tree.declvar.map((v) => ({ name: v.name, desc: v.desc })), subtree: [], }; - b3util.refreshDeclare(treeModel.root, treeModel.group, declare); - if (!b3util.checkNodeData(treeModel?.root)) { + b3util.refreshDeclare(tree.root, tree.group, declare); + if (!b3util.checkNodeData(tree?.root)) { hasError = true; } fs.mkdirSync(Path.dirname(buildpath), { recursive: true }); - fs.writeFileSync(buildpath, JSON.stringify(treeModel, null, 2)); + fs.writeFileSync(buildpath, JSON.stringify(tree, null, 2)); } } if (hasError) { diff --git a/public/icons/Condition.svg b/public/icons/Condition.svg index 57552db..acebc58 100644 --- a/public/icons/Condition.svg +++ b/public/icons/Condition.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/Decorator.svg b/public/icons/Decorator.svg index 4b10007..2c214cf 100644 --- a/public/icons/Decorator.svg +++ b/public/icons/Decorator.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/Error.svg b/public/icons/Error.svg index 0a6d33f..ff10769 100644 --- a/public/icons/Error.svg +++ b/public/icons/Error.svg @@ -1,10 +1 @@ - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/public/locales/en.json b/public/locales/en.json index df2c378..04717c9 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -14,6 +14,7 @@ "console": "Console", "copy": "Copy", "createProject": "Create Project", + "checkExpr": "Check expression", "cut": "Cut", "delete": "Delete", "deleteNode": "Delete Node", @@ -56,6 +57,7 @@ "searchNode": "Search Node", "selectAll": "Select All", "services": "Services", + "setupBuildScript": "Setup Build Script", "start": "Start", "undo": "Undo", "unhide": "Show All", @@ -106,10 +108,11 @@ "node.disabled": "Disabled", "node.editSubtreeDenied": "Unable to edit the subtree node", "node.group": "Node group", + "node.groupNotEnabled": "One of '{{group}}' node group is not enabled in current file", "node.id": "Node id", "node.inputVariable": "Input variables", "node.invalidChildren": "Invalid number of children", - "node.invalidGroup": "Node group '{{group}}' is not enabled", + "node.invalidExpression": "Invalid expression", "node.invalidValue": "Invalid value", "node.invalidVariableName": "Invalid variable name, should start with a letter or underscore", "node.name": "Node name", diff --git a/public/locales/zh.json b/public/locales/zh.json index d4caac3..0346d9f 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -12,6 +12,7 @@ "cancel": "取消", "compact": "紧凑", "console": "控制台", + "checkExpr": "检查表达式", "copy": "复制", "createProject": "新建项目", "cut": "剪切", @@ -56,6 +57,7 @@ "searchNode": "搜索节点", "selectAll": "全选", "services": "服务", + "setupBuildScript": "设置构建脚本", "start": "开始", "undo": "撤销", "unhide": "显示所有", @@ -106,10 +108,11 @@ "node.disabled": "禁用", "node.editSubtreeDenied": "不能编辑子树节点", "node.group": "节点分组", + "node.groupNotEnabled": "节点分组 “{{group}}” 任意一个未在当前文件中启用", "node.id": "节点标识", - "node.invalidChildren": "子节点数量不合法", "node.inputVariable": "输入变量", - "node.invalidGroup": "节点分组 “{{group}}” 未启用", + "node.invalidChildren": "子节点数量不合法", + "node.invalidExpression": "表达式不合法", "node.invalidValue": "无效的值", "node.invalidVariableName": "变量名必须以字母或下划线开头", "node.name": "节点名称", @@ -124,6 +127,5 @@ "node.subtreePathError": "子树必须保存在当前工作区的目录内", "node.type": "节点类型", "node.undefinedVariable": "变量 “{{variable}}” 未定义", - "node.unknown.desc": "新建节点" } diff --git a/sample/scripts/build.js b/sample/scripts/build.js new file mode 100644 index 0000000..07ec94a --- /dev/null +++ b/sample/scripts/build.js @@ -0,0 +1,48 @@ +/* 批处理脚本 */ +/* + 如果有processTree,则先运行prcessTree,如果有返回,则进行后面的针对每个节点运行processNode + 如果没有定义processTree,则直接针对每个节点运行processNode + + export interface TreeModel { + version: string; + name: string; + desc?: string; + export?: boolean; + firstid: number; + group: string[]; + import: string[]; + declvar: VarDef[]; + root: NodeModel; + } + + export interface NodeModel { + id: number; + name: string; + desc?: string; + args?: { [key: string]: any }; + input?: string[]; + output?: string[]; + children?: NodeModel[]; + debug?: boolean; + disabled?: boolean; + path?: string; + } + + interface BuildScript { + processTree?(tree: TreeModel, path: string): TreeModel | null; + + processNode?(node: NodeModel, tree: TreeModel): NodeModel | null; + + writeFile?(path: string, tree: TreeModel): void; + } +*/ +({ + processTree: (tree) => { + console.log(`processTree ${tree.name}`); + return tree; + }, + processNode: (node, tree) => { + console.log(`processNode ${tree.name} ${node.name}#${node.id}`); + return node; + }, +}); diff --git a/sample/scripts/test.js b/sample/scripts/test.js index 3fab5c2..f5bba42 100644 --- a/sample/scripts/test.js +++ b/sample/scripts/test.js @@ -4,23 +4,28 @@ 如果没有定义processTree,则直接针对每个节点运行processNode export interface TreeModel { - name: string; - desc?: string; - export?: boolean; - root: NodeModel; + version: string; + name: string; + desc?: string; + export?: boolean; + firstid: number; + group: string[]; + import: string[]; + declvar: VarDef[]; + root: NodeModel; } export interface NodeModel { - id: number; - name: string; - desc?: string; - args?: { [key: string]: any }; - input?: string[]; - output?: string[]; - children?: NodeModel[]; - debug?: boolean; - disabled?: boolean; - path?: string; + id: number; + name: string; + desc?: string; + args?: { [key: string]: any }; + input?: string[]; + output?: string[]; + children?: NodeModel[]; + debug?: boolean; + disabled?: boolean; + path?: string; } interface BatchScript { diff --git a/sample/vars/test-vars.json b/sample/vars/test-vars.json index 23e3405..a2351c6 100644 --- a/sample/vars/test-vars.json +++ b/sample/vars/test-vars.json @@ -50,7 +50,7 @@ "id": 6, "name": "Check", "args": { - "value": "target1.x > 0" + "value": "target[0].x > 0 |" } }, { diff --git a/sample/workdir/hero.json b/sample/workdir/hero.json index 6240033..8e6d037 100644 --- a/sample/workdir/hero.json +++ b/sample/workdir/hero.json @@ -1,55 +1,120 @@ { - "name": "hero", - "root": { - "id": 1, - "name": "Selector", - "desc": "英雄测试AI", + "name": "hero", + "root": { + "id": 1, + "name": "Selector", + "desc": "英雄测试AI", + "args": {}, + "children": [ + { + "id": 2, + "name": "Sequence", + "desc": "攻击", "args": {}, "children": [ - { - "id": 2, - "name": "Sequence", - "desc": "攻击", - "args": {}, - "children": [ - { - "id": 3, - "name": "FindEnemy", - "args": { "x": 0, "y": 0, "w": 100, "h": 50 }, - "output": ["enemy"] - }, - { "id": 4, "name": "Attack", "args": {}, "input": ["enemy"] }, - { "id": 5, "name": "Wait", "args": { "time": 10 } } - ] + { + "id": 3, + "name": "FindEnemy", + "args": { + "x": 0, + "y": 0, + "w": 100, + "h": 50 }, - { - "id": 6, - "name": "Sequence", - "desc": "移动", - "args": {}, - "children": [ - { - "id": 7, - "name": "FindEnemy", - "args": { "w": 1000, "h": 500, "x": 0, "y": 0 }, - "output": ["enemy"] - }, - { "id": 8, "name": "MoveToTarget", "args": {}, "input": ["enemy"] } - ] - }, - { - "id": 9, - "name": "Sequence", - "desc": "逃跑", - "args": {}, - "children": [ - { "id": 10, "name": "GetHp", "args": {}, "output": ["hp"] }, - { "id": 11, "name": "Cmp", "args": { "lt": 50 }, "input": ["hp"] }, - { "id": 12, "name": "MoveToPos", "args": { "x": 0, "y": 0 } } - ] - }, - { "id": 13, "name": "Idle" } + "output": [ + "enemy" + ] + }, + { + "id": 4, + "name": "Attack", + "args": {}, + "input": [ + "enemy" + ] + }, + { + "id": 5, + "name": "Wait", + "args": { + "time": 10 + } + } ] - }, - "desc": "英雄测试AI" -} + }, + { + "id": 6, + "name": "Sequence", + "desc": "移动", + "args": {}, + "children": [ + { + "id": 7, + "name": "FindEnemy", + "args": { + "w": 1000, + "h": 500, + "x": 0, + "y": 0 + }, + "output": [ + "enemy" + ] + }, + { + "id": 8, + "name": "MoveToTarget", + "args": {}, + "input": [ + "enemy" + ] + } + ] + }, + { + "id": 9, + "name": "Sequence", + "desc": "逃跑", + "args": {}, + "children": [ + { + "id": 10, + "name": "GetHp", + "args": {}, + "output": [ + "hp" + ] + }, + { + "id": 11, + "name": "Cmp", + "args": { + "lt": 50 + }, + "input": [ + "hp" + ] + }, + { + "id": 12, + "name": "MoveToPos", + "args": { + "x": 0, + "y": 0 + } + } + ] + }, + { + "id": 13, + "name": "Idle" + } + ] + }, + "desc": "英雄测试AI", + "version": "1.8.0", + "firstid": 1, + "group": [], + "import": [], + "declvar": [] +} \ No newline at end of file diff --git a/sample/workdir/sub/subtree1.json b/sample/workdir/sub/subtree1.json index afc8a15..7d28abc 100644 --- a/sample/workdir/sub/subtree1.json +++ b/sample/workdir/sub/subtree1.json @@ -1,5 +1,11 @@ { + "version": "1.8.0", "name": "subtree1", + "desc": "子树1", + "firstid": 1, + "group": [], + "import": [], + "declvar": [], "root": { "id": 1, "name": "Sequence", @@ -9,7 +15,7 @@ "id": 2, "name": "Log", "args": { - "str": "hello" + "message": "d" } }, { @@ -17,15 +23,8 @@ "name": "Wait", "args": { "time": 1 - }, - "children": [ - { - "id": 4, - "name": "unknow" - } - ] + } } ] - }, - "desc": "子树1" + } } \ No newline at end of file diff --git a/sample/workdir/sub/subtree2.json b/sample/workdir/sub/subtree2.json index 67cd082..324a819 100644 --- a/sample/workdir/sub/subtree2.json +++ b/sample/workdir/sub/subtree2.json @@ -1,5 +1,11 @@ { + "version": "1.8.0", "name": "subtree2", + "desc": "子树2", + "firstid": 1, + "group": [], + "import": [], + "declvar": [], "root": { "id": 1, "name": "Sequence", @@ -8,9 +14,8 @@ { "id": 2, "name": "Cmp", - "args": {}, "input": [ - "" + "d" ] }, { @@ -21,6 +26,5 @@ ] } ] - }, - "desc": "子树2" + } } \ No newline at end of file diff --git a/sample/workdir/subtree2.json b/sample/workdir/subtree2.json index 9f73205..503e5a8 100644 --- a/sample/workdir/subtree2.json +++ b/sample/workdir/subtree2.json @@ -1,5 +1,11 @@ { + "version": "1.8.0", "name": "subtree2", + "desc": "子树2", + "firstid": 1, + "group": [], + "import": [], + "declvar": [], "root": { "id": 1, "name": "Sequence", @@ -10,14 +16,9 @@ "name": "Cmp", "args": {}, "input": [ - "" + "x" ] - }, - { - "id": 3, - "name": "unknow" } ] - }, - "desc": "子树2" + } } \ No newline at end of file diff --git a/sample/workspace.b3-workspace b/sample/workspace.b3-workspace index be3beb0..f4ad632 100644 --- a/sample/workspace.b3-workspace +++ b/sample/workspace.b3-workspace @@ -40,5 +40,9 @@ "path": "workdir/subtree2.json", "desc": "子树2" } - ] + ], + "settings": { + "checkExpr": true, + "buildScript": "scripts/build.js" + } } \ No newline at end of file diff --git a/src/behavior3 b/src/behavior3 index 14563ec..f45acd9 160000 --- a/src/behavior3 +++ b/src/behavior3 @@ -1 +1 @@ -Subproject commit 14563ec920d6463b1a2ba87202a07c1630911426 +Subproject commit f45acd9d309156eb3fe05df4d68c2a3015c3f5cc diff --git a/src/components/editor.tsx b/src/components/editor.tsx index 6cb635e..5021f9e 100644 --- a/src/components/editor.tsx +++ b/src/components/editor.tsx @@ -1253,32 +1253,22 @@ export const Editor: FC = ({ onUpdate: updateState, data: editor, . editor.size.height = editorSize.height; } - if (typeof editor.editNode === "number") { - const id = editor.editNode.toString(); - selectNode(null); + if (editor.selectedId) { + const id = editor.selectedId; setTimeout(() => { editor.graph.focusItem(id); selectNode(id); - }, 100); + }, 50); } - }); + }, [editorSize, workspace.editing]); - // check should subtree + // check should repaint node useEffect(() => { if (workspace.editing === editor) { checkSubtree(); editor.graph && refresh(); } - }, [workspace.editing]); - - // check should repaint node - useEffect(() => { - if (editor.graph) { - editor.graph.changeData(editor.root); - editor.graph.layout(); - restoreViewport(); - } - }, [t]); + }, [workspace.editing, t]); const nextResult = () => { const { results, index } = filterOption; diff --git a/src/components/explorer.tsx b/src/components/explorer.tsx index 80bb885..53d5528 100644 --- a/src/components/explorer.tsx +++ b/src/components/explorer.tsx @@ -876,18 +876,6 @@ export const Explorer: FC = () => { if (node && !node.editing) { dispatch("open", node); setSelectedKeys([node.path]); - - setTimeout(() => { - const element = document.querySelector(`[title="${node.title}"]`); - const container = - element?.closest(".b3-overflow") || element?.closest(".b3-explorer"); - if (element && container && !isElementInViewport(element, container)) { - element.scrollIntoView({ - behavior: "smooth", - block: "center", - }); - } - }, 100); } }} onDrop={(info) => { diff --git a/src/components/inspector.tsx b/src/components/inspector.tsx index 861d554..698002a 100644 --- a/src/components/inspector.tsx +++ b/src/components/inspector.tsx @@ -26,6 +26,7 @@ import { useTranslation } from "react-i18next"; import Markdown from "react-markdown"; import { useDebounceCallback } from "usehooks-ts"; import { useShallow } from "zustand/react/shallow"; +import { ExpressionEvaluator } from "../behavior3/src/behavior3"; import { EditNode, EditTree, useWorkspace } from "../contexts/workspace-context"; import { ImportDef, @@ -36,6 +37,7 @@ import { isIntType, isJsonType, isStringType, + NodeArg, NodeModel, TreeGraphData, VarDef, @@ -53,6 +55,7 @@ import { usingGroups, usingVars, } from "../misc/b3util"; +import i18n from "../misc/i18n"; import { Hotkey, isMacos } from "../misc/keys"; import { mergeClassNames } from "../misc/util"; @@ -550,6 +553,43 @@ const TreeInspector: FC = () => { ); }; +const validateArg = (node: NodeModel, arg: NodeArg, value: unknown) => { + const type = getNodeArgRawType(arg); + const required = !isNodeArgOptional(arg); + if (isExprType(type) && value) { + for (const v of parseExpr(value as string)) { + if (usingVars && !usingVars[v]) { + return Promise.reject(new Error(i18n.t("node.undefinedVariable", { variable: v }))); + } + } + if (useWorkspace.getState().settings.checkExpr) { + try { + if (!new ExpressionEvaluator(value as string).dryRun()) { + return Promise.reject(new Error(i18n.t("node.invalidExpression"))); + } + } catch (e) { + console.error(e); + return Promise.reject(new Error(i18n.t("node.invalidExpression"))); + } + } + } + if (value && isJsonType(type)) { + try { + if (value !== "null") { + JSON.parse(value as string); + } + } catch (e) { + return Promise.reject(new Error(i18n.t("node.invalidValue"))); + } + } else if (value === null && !required) { + value = undefined; + } + if (!checkNodeArgValue(node, arg, value, true)) { + return Promise.reject(new Error(i18n.t("node.invalidValue"))); + } + return Promise.resolve(value); +}; + const NodeInspector: FC = () => { const workspace = useWorkspace( useShallow((state) => ({ @@ -842,7 +882,9 @@ const NodeInspector: FC = () => { { validator() { if (def.group && !def.group.some((g) => usingGroups[g])) { - return Promise.reject(new Error(t("node.invalidGroup", { group: def.group }))); + return Promise.reject( + new Error(t("node.groupNotEnabled", { group: def.group })) + ); } return Promise.resolve(); }, @@ -1105,33 +1147,7 @@ const NodeInspector: FC = () => { }, () => ({ validator(_, value) { - if (usingVars && isExprType(type) && value) { - for (const v of parseExpr(value)) { - if (!usingVars![v]) { - return Promise.reject( - new Error( - t("node.undefinedVariable", { variable: v }) - ) - ); - } - } - } - if (isJsonType(type)) { - try { - if (value !== "null") { - JSON.parse(value); - } - } catch (e) { - return Promise.reject( - new Error(t("node.invalidValue")) - ); - } - } else if ( - !checkNodeArgValue(editingNode.data, arg, value, true) - ) { - return Promise.reject(new Error(t("node.invalidValue"))); - } - return Promise.resolve(); + return validateArg(editingNode.data, arg, value); }, }), ]} @@ -1228,59 +1244,39 @@ const NodeInspector: FC = () => { rules={[ { required, message: t("fieldRequired", { field: arg.desc }) }, ({ getFieldValue, setFieldValue, isFieldValidating, validateFields }) => ({ - validator(_, value) { - if (usingVars && isExprType(type) && value) { - for (const v of parseExpr(value)) { - if (!usingVars![v]) { - return Promise.reject( - new Error(t("node.undefinedVariable", { variable: v })) - ); - } + async validator(_, value) { + return validateArg(editingNode.data, arg, value).then((result) => { + value = result; + if (!arg.oneof) { + return Promise.resolve(); } - } - if (value && isJsonType(type)) { - try { - if (value !== "null") { - JSON.parse(value); - } - } catch (e) { - return Promise.reject(new Error(t("node.invalidValue"))); + const idx = def.input?.findIndex( + (input) => input.replace("?", "") === arg.oneof + ); + if (idx === undefined || idx < 0) { + return Promise.reject( + new Error(t("node.oneof.inputNotfound", { input: arg.oneof })) + ); } - } else if (value === null && !required) { - value = undefined; - } - if (!checkNodeArgValue(editingNode.data, arg, value)) { - return Promise.reject(new Error(t("node.invalidValue"))); - } - if (!arg.oneof) { - return Promise.resolve(); - } - const idx = def.input?.findIndex( - (input) => input.replace("?", "") === arg.oneof - ); - if (idx === undefined || idx < 0) { - return Promise.reject( - new Error(t("node.oneof.inputNotfound", { input: arg.oneof })) - ); - } - const inputName = `input.${idx}`; - if (!isFieldValidating(inputName)) { - setFieldValue(`args.${arg.name}`, value); - validateFields([inputName]); - } - if (!checkOneof(getFieldValue(inputName) ?? "", value)) { - return Promise.reject( - new Error( - t("node.oneof.error", { - input: def.input![idx], - arg: arg.name, - desc: arg.desc ?? "", - }) - ) - ); - } else { - return Promise.resolve(); - } + const inputName = `input.${idx}`; + if (!isFieldValidating(inputName)) { + setFieldValue(`args.${arg.name}`, value); + validateFields([inputName]); + } + if (!checkOneof(getFieldValue(inputName) ?? "", value)) { + return Promise.reject( + new Error( + t("node.oneof.error", { + input: def.input![idx], + arg: arg.name, + desc: arg.desc ?? "", + }) + ) + ); + } else { + return Promise.resolve(); + } + }); }, }), ]} diff --git a/src/components/menu.tsx b/src/components/menu.tsx index 8877595..1d7386b 100644 --- a/src/components/menu.tsx +++ b/src/components/menu.tsx @@ -54,6 +54,9 @@ export const Menu: FC = () => { save: state.save, saveAll: state.saveAll, workdir: state.workdir, + settings: state.settings, + setCheckExpr: state.setCheckExpr, + setupBuildScript: state.setupBuildScript, })) ); const enabled = !!workspace.workdir; @@ -165,6 +168,14 @@ export const Menu: FC = () => { workspace.buildProject(); }, }, + { + id: "menu.file.buildScript", + label: t("setupBuildScript"), + enabled: enabled, + click: () => { + workspace.setupBuildScript(); + }, + }, { id: "menu.file.batch", label: t("batch"), @@ -255,6 +266,17 @@ export const Menu: FC = () => { // workspace.editing?.dispatch("delete"); }, }, + { type: "separator" }, + { + id: "menu.edit.checkExpr", + label: t("checkExpr"), + enabled: enabled, + type: "checkbox", + checked: workspace.settings.checkExpr, + click: () => { + workspace.setCheckExpr(!workspace.settings.checkExpr); + }, + }, ], }, // { role: 'viewMenu' } @@ -394,7 +416,7 @@ export const Menu: FC = () => { ], }, ] as MenuItemConstructorOptions[]; - }, [t, workspace.workdir, settings.recent, workspace.editing, settings]); + }, [t, workspace.workdir, settings.recent, workspace.editing, workspace.settings, settings]); if (isMacos) { const menu = AppMenu.buildFromTemplate(menuTemplate); @@ -446,7 +468,7 @@ export const Menu: FC = () => { label: (
- {option.type === "radio" && option.checked && ( + {(option.type === "radio" || option.type === "checkbox") && option.checked && ( )} {option.type === "radio" && !option.checked && ( diff --git a/src/components/register-node.ts b/src/components/register-node.ts index 1fb2402..5c3cb2f 100644 --- a/src/components/register-node.ts +++ b/src/components/register-node.ts @@ -1,5 +1,6 @@ import G6 from "@antv/g6"; -import { NodeDef } from "../behavior3/src/behavior3"; +import { ExpressionEvaluator, NodeDef } from "../behavior3/src/behavior3"; +import { useSetting } from "../contexts/setting-context"; import { useWorkspace } from "../contexts/workspace-context"; import { TreeGraphData, getNodeType, isExprType } from "../misc/b3type"; import { checkTreeData, nodeDefs, parseExpr, usingGroups, usingVars } from "../misc/b3util"; @@ -11,9 +12,6 @@ let defaultFontSize = ""; const textWidthMap = new Map(); const textLines: Record = {}; -let layoutWidth = 220; -let layoutStyle: "compact" | "normal" = "compact"; - const calcTextWith = (text: string, fontSize?: string) => { fontSize = fontSize ?? defaultFontSize; let b3Workspace: HTMLDivElement | null; @@ -133,7 +131,40 @@ const foundUndefinedInArgs = (def: NodeDef, data: TreeGraphData) => { return false; }; +const hasErrorInArgExpr = (def: NodeDef, data: TreeGraphData) => { + const checkExpr = useWorkspace.getState().settings.checkExpr; + if (!checkExpr || !def.args || !data.args) { + return false; + } + for (const arg of def.args) { + if (!isExprType(arg.type)) { + continue; + } + const expr = data.args[arg.name] as string | string[] | undefined; + if (!expr) { + continue; + } + try { + if (typeof expr === "string") { + if (!new ExpressionEvaluator(expr).dryRun()) { + return true; + } + } else if (Array.isArray(expr)) { + for (const str of expr) { + if (!new ExpressionEvaluator(str).dryRun()) { + return true; + } + } + } + } catch (e) { + return true; + } + } + return false; +}; + export const calcTreeDataSize = (data: TreeGraphData) => { + const width = useSetting.getState().data.layout === "compact" ? 220 : 260; let height = 50 + 2; const updateHeight = (obj: unknown) => { if ((Array.isArray(obj) && obj.length) || (obj && Object.keys(obj).length > 0)) { @@ -147,16 +178,7 @@ export const calcTreeDataSize = (data: TreeGraphData) => { updateHeight(data.args); updateHeight(data.input); updateHeight(data.output); - return [layoutWidth, height]; -}; - -export const setLayoutStyle = (style: "compact" | "normal") => { - layoutStyle = style; - if (style === "compact") { - layoutWidth = 220; - } else { - layoutWidth = 260; - } + return [width, height]; }; const NODE_COLORS = { @@ -217,6 +239,8 @@ G6.registerNode( let classify = getNodeType(nodeDef); let color = nodeDef.color || NODE_COLORS[classify] || NODE_COLORS["Other"]; + const layoutStyle = useSetting.getState().data.layout; + if ( !nodeDefs.has(data.name) || (data.path && !data.children?.length) || @@ -224,7 +248,8 @@ G6.registerNode( !checkTreeData(data) || foundUndefined(data.input) || foundUndefined(data.output) || - foundUndefinedInArgs(nodeDef, data) + foundUndefinedInArgs(nodeDef, data) || + hasErrorInArgExpr(nodeDef, data) ) { classify = "Error"; color = NODE_COLORS[classify]; diff --git a/src/contexts/setting-context.ts b/src/contexts/setting-context.ts index 4dff376..ec9ebad 100644 --- a/src/contexts/setting-context.ts +++ b/src/contexts/setting-context.ts @@ -1,7 +1,6 @@ import { app } from "@electron/remote"; import * as fs from "fs"; import { create } from "zustand"; -import { setLayoutStyle } from "../components/register-node"; import { readJson, writeJson } from "../misc/util"; import { useWorkspace } from "./workspace-context"; @@ -29,6 +28,7 @@ export const useSetting = create((set, get) => ({ recent: [], buildDir: "", layout: "compact", + checkExpr: true, }, load: () => { try { @@ -36,7 +36,6 @@ export const useSetting = create((set, get) => ({ const settings = readJson(settingPath) as SettingModel; settings.layout = settings.layout || "compact"; set({ data: settings }); - setLayoutStyle(settings.layout); } } catch (error) { console.error(error); @@ -67,7 +66,6 @@ export const useSetting = create((set, get) => ({ const { data, save } = get(); set({ data: { ...data, layout } }); save(); - setLayoutStyle(layout); useWorkspace.getState().editing?.dispatch("refresh"); }, })); diff --git a/src/contexts/workspace-context.ts b/src/contexts/workspace-context.ts index 5d4ed8f..a4fc28c 100644 --- a/src/contexts/workspace-context.ts +++ b/src/contexts/workspace-context.ts @@ -18,17 +18,11 @@ import { message } from "../misc/hooks"; import i18n from "../misc/i18n"; import Path from "../misc/path"; import { zhNodeDef } from "../misc/template"; -import { readJson, readTree, writeJson } from "../misc/util"; +import { readJson, readTree, readWorkspace, writeJson } from "../misc/util"; import { useSetting } from "./setting-context"; let buildDir: string | undefined; -interface BatchScript { - processTree?(tree: TreeModel, path: string): TreeModel; - - processNode?(node: NodeModel, tree: TreeModel): NodeModel; -} - export type EditEvent = | "save" | "copy" @@ -72,8 +66,6 @@ export class EditorStore { graphMatrix?: Matrix; graph!: TreeGraph; - editNode: EditNode | number | null = null; - dispatch!: (event: EditEvent, data?: unknown) => void; constructor(path: string) { @@ -142,8 +134,12 @@ export type FileMeta = { exists?: boolean; }; -interface WorkspaceModel { +export interface WorkspaceModel { files?: { path: string; desc: string }[]; + settings: { + checkExpr?: boolean; + buildScript?: string; + }; } export type WorkspaceStore = { @@ -153,9 +149,14 @@ export type WorkspaceStore = { batchProject: () => void; buildProject: () => void; + settings: WorkspaceModel["settings"]; workdir: string; path: string; + // settings + setCheckExpr: (checkExpr: boolean) => void; + setupBuildScript: () => void; + loadWorkspace: () => void; saveWorkspace: () => void; updateFileMeta: (editor: EditorStore) => void; @@ -170,8 +171,8 @@ export type WorkspaceStore = { isShowingSearch: boolean; onShowingSearch: (isShowingSearch: boolean) => void; - open: (path: string, selectedNode?: number) => void; - edit: (path: string, selectedNode?: number) => void; + open: (path: string, selectedId?: number) => void; + edit: (path: string, selectedId?: number) => void; close: (path: string) => void; find: (path: string) => EditorStore | undefined; relative: (path: string) => string; @@ -250,6 +251,7 @@ export const useWorkspace = create((set, get) => ({ modifiedEditors: [], workdir: "", path: "", + settings: {}, init: (path) => { const workspace = get(); @@ -272,6 +274,40 @@ export const useWorkspace = create((set, get) => ({ } }, + // setting + setCheckExpr: (checkExpr: boolean) => { + const { settings, saveWorkspace } = get(); + set({ + settings: { + ...settings, + checkExpr, + }, + }); + b3util.setCheckExpr(checkExpr); + saveWorkspace(); + useWorkspace.getState().editing?.dispatch("refresh"); + }, + + setupBuildScript: () => { + const workspace = get(); + let buildScript = dialog.showOpenDialogSync({ + properties: ["openFile"], + defaultPath: workspace.workdir, + filters: [{ name: "Javascript", extensions: ["js"] }], + })?.[0]; + if (buildScript) { + buildScript = Path.posixPath(buildScript); + const { settings, saveWorkspace, relative } = get(); + set({ + settings: { + ...settings, + buildScript: relative(buildScript), + }, + }); + saveWorkspace(); + } + }, + createProject: () => { const path = dialog.showSaveDialogSync({ properties: ["showOverwriteConfirmation", "createDirectory"], @@ -355,30 +391,46 @@ export const useWorkspace = create((set, get) => ({ for (const editor of workspace.editors) { editor.dispatch("save"); } + const debug = console.debug; + console.debug = () => {}; try { let hasError = false; + + let buildScript: b3util.BuildScript | undefined; + if (workspace.settings.buildScript) { + const scriptPath = workspace.workdir + "/" + workspace.settings.buildScript; + if (fs.existsSync(scriptPath)) { + buildScript = eval(fs.readFileSync(scriptPath, "utf8")); + } + } + workspace.allFiles.forEach((file) => { const buildpath = buildDir + "/" + workspace.relative(file.path); - const treeModel = b3util.createBuildData(file.path); - if (!treeModel) { + let tree = b3util.createBuildData(file.path); + if (buildScript) { + tree = b3util.processBatch(tree, file.path, buildScript); + } + + if (!tree) { return; } - if (treeModel.export === false) { + + if (tree.export === false) { console.log("skip:", buildpath); return; } console.log("build:", buildpath); const declare: FileVarDecl = { - import: treeModel.import.map((v) => ({ path: v, vars: [], depends: [] })), - declvar: treeModel.declvar.map((v) => ({ name: v.name, desc: v.desc })), + import: tree.import.map((v) => ({ path: v, vars: [], depends: [] })), + declvar: tree.declvar.map((v) => ({ name: v.name, desc: v.desc })), subtree: [], }; - b3util.refreshDeclare(treeModel.root, treeModel.group, declare); - if (!b3util.checkNodeData(treeModel?.root)) { + b3util.refreshDeclare(tree.root, tree.group, declare); + if (!b3util.checkNodeData(tree?.root)) { hasError = true; } fs.mkdirSync(Path.dirname(buildpath), { recursive: true }); - fs.writeFileSync(buildpath, JSON.stringify(treeModel, null, 2)); + fs.writeFileSync(buildpath, JSON.stringify(tree, null, 2)); }); if (hasError) { message.error(i18n.t("buildFailed")); @@ -390,6 +442,7 @@ export const useWorkspace = create((set, get) => ({ console.error(error); message.error(i18n.t("buildFailed")); } + console.debug = debug; } }, @@ -405,28 +458,10 @@ export const useWorkspace = create((set, get) => ({ try { console.log("run script", scriptPath); const str = fs.readFileSync(scriptPath, "utf8"); - const batch = eval(str) as BatchScript; + const batch = eval(str) as b3util.BuildScript; workspace.allFiles.forEach((file) => { - let tree: TreeModel | undefined = readTree(file.path); - if (batch.processTree && tree) { - tree = batch.processTree(tree, file.path); - } - if (tree && batch.processNode) { - const processNode = (node: NodeModel) => { - if (node.children) { - const children: NodeModel[] = []; - node.children?.forEach((child) => { - const newChild = processNode(child); - if (newChild) { - children.push(newChild); - } - }); - node.children = children; - } - return batch.processNode?.(node, tree!); - }; - tree.root = processNode(tree.root) ?? ({} as NodeModel); - } + let tree: TreeModel | null = readTree(file.path); + tree = b3util.processBatch(tree, file.path, batch); if (tree) { fs.writeFileSync(file.path, JSON.stringify(tree, null, 2)); } @@ -445,7 +480,9 @@ export const useWorkspace = create((set, get) => ({ loadWorkspace: () => { const workspace = get(); - const data = readJson(workspace.path) as WorkspaceModel; + const data = readWorkspace(workspace.path); + set({ settings: data.settings }); + b3util.setCheckExpr(data.settings?.checkExpr ?? true); data.files?.forEach((file) => { workspace.allFiles.set(file.path, { path: file.path, desc: file.desc, exists: false }); }); @@ -455,6 +492,7 @@ export const useWorkspace = create((set, get) => ({ const workspace = get(); const data: WorkspaceModel = { files: [], + settings: workspace.settings, }; workspace.allFiles.forEach((file) => { data.files?.push({ @@ -484,7 +522,7 @@ export const useWorkspace = create((set, get) => ({ set({ isShowingSearch }); }, - open: (path, selectedNode) => { + open: (path, selectedId) => { path = Path.posixPath(path); const workspace = get(); let editor = workspace.editors.find((v) => v.path === path); @@ -494,7 +532,7 @@ export const useWorkspace = create((set, get) => ({ workspace.editors.push(editor); set({ editors: workspace.editors }); workspace.updateFileMeta(editor); - workspace.edit(editor.path, selectedNode); + workspace.edit(editor.path, selectedId); if (b3util.isNewVersion(editor.data.version)) { message.warning(i18n.t("alertNewVersion", { version: editor.data.version })); @@ -504,26 +542,20 @@ export const useWorkspace = create((set, get) => ({ message.error(`invalid file: ${path}`); } } else if (workspace.editing !== editor) { - workspace.edit(editor.path, selectedNode); + workspace.edit(editor.path, selectedId); } }, - edit: (path, selectedNode) => { + edit: (path, selectedId) => { const workspace = get(); const editor = workspace.editors.find((v) => v.path === path); - if (editor && selectedNode) { - editor.editNode = selectedNode; + if (editor && selectedId) { + editor.selectedId = selectedId.toString(); } set({ editing: editor, editingNode: null, editingTree: null }); if (editor) { workspace.refresh(editor.path); - if (!editor.editNode) { - workspace.onEditingTree(editor); - } else if (typeof editor.editNode === "object") { - workspace.onEditingNode(editor.editNode); - } - } else { - set({ editingNode: null, editingTree: null }); + workspace.onEditingTree(editor); } }, @@ -675,27 +707,16 @@ export const useWorkspace = create((set, get) => ({ // node edit onEditingNode: (node) => { - const workspace = get(); - if (workspace.editing) { - workspace.editing.editNode = node; - } set({ editingNode: node, editingNodeDef: null, editingTree: null }); }, onEditingNodeDef: (nodeDef) => { - const workspace = get(); - if (workspace.editing) { - workspace.editing.editNode = null; - } set({ editingNodeDef: nodeDef, editingNode: null, editingTree: null }); }, // tree edit onEditingTree: (editor) => { const workspace = get(); - if (workspace.editing) { - workspace.editing.editNode = null; - } workspace.refresh(editor.path); set({ editingTree: { diff --git a/src/misc/b3util.ts b/src/misc/b3util.ts index 068c381..bafbb4d 100644 --- a/src/misc/b3util.ts +++ b/src/misc/b3util.ts @@ -1,5 +1,5 @@ import * as fs from "fs"; -import { NodeDef } from "../behavior3/src/behavior3"; +import { ExpressionEvaluator, NodeDef } from "../behavior3/src/behavior3"; import { FileVarDecl, ImportDef, @@ -20,6 +20,16 @@ import { import Path from "./path"; import { readJson, readTree } from "./util"; +export interface BatchScript { + processTree?(tree: TreeModel, path: string): TreeModel | null; + + processNode?(node: NodeModel, tree: TreeModel): NodeModel | null; +} + +export interface BuildScript extends BatchScript { + writeFile?(path: string, tree: TreeModel): void; +} + export class NodeDefs extends Map { get(key: string): NodeDef { return super.get(key) ?? unknownNodeDef; @@ -34,6 +44,7 @@ export const files: Record = {}; const parsedVarDefs: Record = {}; const parsedExprs: Record = {}; +let checkExpr: boolean = false; let workdir: string = ""; let alertError: (msg: string, duration?: number) => void = () => {}; @@ -78,6 +89,10 @@ export const updateUsingVars = (vars: VarDef[]) => { } }; +export const setCheckExpr = (check: boolean) => { + checkExpr = check; +}; + export const parseExpr = (expr: string) => { if (parsedExprs[expr]) { return parsedExprs[expr]; @@ -323,11 +338,13 @@ export const checkNodeData = (data: NodeModel | null | undefined) => { } } } + } - if (data.args && conf.args) { - for (const arg of conf.args) { - const value = data.args?.[arg.name] as string | string[] | undefined; - if (isExprType(arg.type)) { + if (data.args && conf.args) { + for (const arg of conf.args) { + const value = data.args?.[arg.name] as string | string[] | undefined; + if (isExprType(arg.type) && value) { + if (usingVars) { const vars: string[] = []; if (typeof value === "string") { vars.push(...parseExpr(value)); @@ -343,6 +360,27 @@ export const checkNodeData = (data: NodeModel | null | undefined) => { } } } + if (checkExpr) { + const exprs: string[] = []; + if (typeof value === "string") { + exprs.push(value); + } else if (Array.isArray(value)) { + for (const v of value) { + exprs.push(v); + } + } + for (const expr of exprs) { + try { + if (!new ExpressionEvaluator(expr).dryRun()) { + error(data, `expr '${expr}' is not valid`); + hasError = true; + } + } catch (e) { + error(data, `expr '${expr}' is not valid`); + hasError = true; + } + } + } } } } @@ -740,6 +778,35 @@ export const createBuildData = (path: string) => { return null; }; +export const processBatch = (tree: TreeModel | null, path: string, batch: BatchScript) => { + if (!tree) { + return null; + } + if (batch.processTree) { + tree = batch.processTree(tree, path); + } + if (!tree) { + return null; + } + if (batch.processNode) { + const processNode = (node: NodeModel) => { + if (node.children) { + const children: NodeModel[] = []; + node.children?.forEach((child) => { + const newChild = processNode(child); + if (newChild) { + children.push(newChild); + } + }); + node.children = children; + } + return batch.processNode?.(node, tree); + }; + tree.root = processNode(tree.root) ?? ({} as NodeModel); + } + return tree; +}; + export const createFileData = (data: TreeGraphData, includeSubtree?: boolean) => { const nodeData: NodeModel = { id: Number(data.id), @@ -887,4 +954,5 @@ export const refreshDeclare = ( }); updateUsingGroups(group); updateUsingVars(Array.from(vars)); + console.debug("refresh declare", group, vars); }; diff --git a/src/misc/util.ts b/src/misc/util.ts index 54842a9..8d488e5 100644 --- a/src/misc/util.ts +++ b/src/misc/util.ts @@ -1,13 +1,20 @@ import * as fs from "fs"; +import { type WorkspaceModel } from "../contexts/workspace-context"; import { VERSION, type TreeModel } from "./b3type"; -export const readJson = (path: string) => { +export const readJson = (path: string): T => { const str = fs.readFileSync(path, "utf-8"); return JSON.parse(str); }; +export const readWorkspace = (path: string) => { + const data = readJson(path) as WorkspaceModel; + data.settings = data.settings ?? {}; + return data; +}; + export const readTree = (path: string) => { - const data = readJson(path); + const data = readJson(path) as TreeModel; data.version = data.version ?? VERSION; data.firstid = data.firstid ?? 1; data.group = data.group || [];