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

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