Files
behavior3editor/src/components/editor.tsx
T

1439 lines
39 KiB
TypeScript

import { ArrowDownOutlined, ArrowUpOutlined, CloseOutlined } from "@ant-design/icons";
import G6, { G6GraphEvent, Item, TreeGraph } from "@antv/g6";
import { dialog } from "@electron/remote";
import { useSize } from "ahooks";
import { Button, Dropdown, Flex, FlexProps, Input, InputRef, MenuProps } from "antd";
import { clipboard } from "electron";
import * as fs from "fs";
import React, { FC, KeyboardEvent, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FiDelete } from "react-icons/fi";
import { IoMdReturnLeft } from "react-icons/io";
import { RiFocus3Line } from "react-icons/ri";
import { VscCaseSensitive } from "react-icons/vsc";
import { mergeRefs } from "react-merge-refs";
import { useDebounceCallback } from "usehooks-ts";
import { useShallow } from "zustand/react/shallow";
import {
EditEvent,
EditNode,
EditorStore,
EditTree,
useWorkspace,
} from "../contexts/workspace-context";
import { isExprType, NodeModel, TreeGraphData, TreeModel } from "../misc/b3type";
import * as b3util from "../misc/b3util";
import { message } from "../misc/hooks";
import i18n from "../misc/i18n";
import { Hotkey, isMacos, useKeyDown } from "../misc/keys";
import Path from "../misc/path";
import { mergeClassNames, readJson } from "../misc/util";
import "./register-node";
import { calcTreeDataSize } from "./register-node";
export interface EditorProps extends React.HTMLAttributes<HTMLElement> {
data: EditorStore;
onUpdate: () => void;
}
interface FilterOption {
results: string[];
index: number;
filterStr: string;
filterCase: boolean;
filterFocus: boolean;
filterType: "content" | "id";
placeholder: string;
isFocused: boolean;
}
const createTreeData = (node: NodeModel, parent?: string) => {
return b3util.createTreeData(node, parent, calcTreeDataSize);
};
const refreshTreeData = (data: TreeGraphData) => {
data.size = calcTreeDataSize(data);
if (data.children) {
for (const child of data.children) {
refreshTreeData(child);
}
}
};
const isSubtreeUpdated = (data: TreeGraphData) => {
if (data.path) {
try {
const subtreePath = useWorkspace.getState().workdir + "/" + data.path;
if (fs.statSync(subtreePath).mtimeMs !== data.lastModified) {
return true;
}
} catch (error) {
return true;
}
}
if (data.children) {
for (const child of data.children) {
if (isSubtreeUpdated(child)) {
return true;
}
}
}
return false;
};
const createMenu = () => {
const t = i18n.t;
const MenuItem: FC<FlexProps> = (itemProps) => {
return (
<Flex
gap="50px"
style={{ minWidth: "200px", justifyContent: "space-between", alignItems: "center" }}
{...itemProps}
></Flex>
);
};
const arr: MenuProps["items"] = [
{
label: (
<MenuItem>
<div>{t("copy")}</div>
<div>{isMacos ? "⌘ C" : "Ctrl+C"}</div>
</MenuItem>
),
key: "copy",
},
{
label: (
<MenuItem>
<div>{t("paste")}</div>
<div>{isMacos ? "⌘ V" : "Ctrl+V"}</div>
</MenuItem>
),
key: "paste",
},
{
label: (
<MenuItem>
<div>{t("replace")}</div>
<div>{isMacos ? "⇧ ⌘ V" : "Ctrl+Shift+V"} </div>
</MenuItem>
),
key: "replace",
},
{
label: (
<MenuItem>
<div>{t("insertNode")}</div>
<div>{isMacos ? <IoMdReturnLeft /> : "Enter"}</div>
</MenuItem>
),
key: "insert",
},
{
label: (
<MenuItem>
<div>{t("deleteNode")}</div>
<div>{isMacos ? <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;
};
export const Editor: FC<EditorProps> = ({ onUpdate: updateState, data: editor, ...props }) => {
const workspace = useWorkspace(
useShallow((state) => ({
editing: state.editing,
onEditingNode: state.onEditingNode,
onEditingTree: state.onEditingTree,
open: state.open,
workdir: state.workdir,
relative: state.relative,
updateFileMeta: state.updateFileMeta,
nodeDefs: state.nodeDefs,
}))
);
const searchInputRef = useRef<InputRef>(null);
const graphRef = useRef(null);
const sizeRef = useRef(null);
const editorSize = useSize(sizeRef);
const { t } = useTranslation();
const menuItems = useMemo(() => createMenu(), [t]);
const [showingSearch, setShowingSearch] = useState(false);
const [filterOption, setFilterOption] = useState<FilterOption>({
results: [],
index: 0,
filterStr: "",
filterCase: false,
filterFocus: true,
filterType: "content",
placeholder: "",
isFocused: false,
});
const onSearchChange = (option: FilterOption) => {
option.results.length = 0;
editor.searchingText = option.filterStr;
filterNodes(option, editor.data);
setFilterOption({
...option,
});
if (option.results.length > 0) {
const idx = option.index < option.results.length ? option.index : 0;
recursiveOpenCollapsed(option.results[idx]);
editor.graph.focusItem(option.results[idx]);
selectNode(option.results[idx]);
} else {
selectNode(null);
}
editor.graph.findAll("node", () => true).forEach((item) => item.draw());
};
const updateSearchState = () => {
const option = { ...filterOption };
option.results.length = 0;
filterNodes(option, editor.data);
setFilterOption({
...option,
});
editor.graph.findAll("node", () => true).forEach((item) => item.draw());
};
const onDebounceSearchChange = useDebounceCallback(onSearchChange, 200);
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.highlightArgs) {
node.highlightInput = false;
node.highlightOutput = false;
node.highlightArgs = 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.def.args?.forEach((arg) => {
if (isExprType(arg.type)) {
const expr: string | string[] = node.args?.[arg.name];
if (typeof expr === "string") {
for (const v of b3util.parseExpr(expr)) {
if (highlight.includes(v)) {
node.highlightArgs = true;
changed.push(node);
break;
}
}
} else if (expr instanceof Array) {
loop: for (const str of expr) {
for (const v of b3util.parseExpr(str)) {
if (highlight.includes(v)) {
node.highlightArgs = true;
changed.push(node);
break loop;
}
}
}
}
}
});
node.children?.forEach((child: TreeGraphData) => findHightlight(child, highlight, changed));
return changed;
};
const findDataById = (id: string) => {
const traverse = (node: TreeGraphData): TreeGraphData | undefined => {
if (node.id === id) {
return node;
}
if (node.children) {
for (const child of node.children) {
const result = traverse(child);
if (result) {
return result;
}
}
}
};
return traverse(editor.data) as TreeGraphData;
};
const includeString = (content: string | undefined, option: FilterOption) => {
if (!content) {
return false;
} else if (option.filterCase) {
return content.includes(option.filterStr);
} else {
return content.toLowerCase().includes(option.filterStr.toLowerCase());
}
};
const filterNodes = (option: FilterOption, node: TreeGraphData | null) => {
if (node) {
node.highlightGray = option.filterFocus && !!option.filterStr;
if (option.filterStr) {
let found = false;
if (option.filterType === "id") {
if (option.filterStr === node.id) {
found = true;
}
} else {
if (
includeString(node.name, option) ||
includeString(node.desc || node.def.desc, option)
) {
found = true;
}
if (!found && node.input) {
for (const str of node.input) {
if (includeString(str, option)) {
found = true;
break;
}
}
}
if (!found && node.args) {
for (const str in node.args) {
if (includeString(str, option) || includeString(node.args[str], option)) {
found = true;
break;
}
}
}
if (!found && node.output) {
for (const str of node.output) {
if (includeString(str, option)) {
found = true;
break;
}
}
}
if (!found && node.path) {
if (includeString(node.path, option)) {
found = true;
}
}
}
if (found) {
option.results.push(node.id);
node.highlightGray = false;
}
}
node.children?.forEach((child: TreeGraphData) => filterNodes(option, child));
}
};
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 refresh = () => {
const rootNode = b3util.createNode(editor.data);
editor.modifiedTime = fs.statSync(editor.path).mtimeMs;
editor.data = createTreeData(rootNode);
editor.autoId = b3util.refreshTreeDataId(editor.data, editor.firstid);
editor.graph.changeData(editor.data);
editor.graph.layout();
restoreViewport();
};
const reload = () => {
const file = readJson(editor.path) as TreeModel;
editor.data = createTreeData(file.root);
editor.autoId = b3util.refreshTreeDataId(editor.data, editor.firstid);
editor.unsave = false;
updateState();
if (editor.graph) {
editor.graph.changeData(editor.data);
editor.graph.layout();
restoreViewport();
}
};
const checkSubtree = () => {
if (editor.graph) {
if (isSubtreeUpdated(editor.data)) {
refresh();
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.def = workspace.nodeDefs.get(data.name)!;
data.size = calcTreeDataSize(data);
if (oldNode.path !== newNode.path) {
refresh();
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.export !== editTree.data.export) {
editor.desc = editTree.data.desc || "";
editor.export = editTree.data.export !== false;
editor.group = editTree.data.group || [];
if (editor.data.firstid !== editTree.data.firstid) {
editor.firstid = editTree.data.firstid ?? 1;
editor.declare.vars = editTree.data.declare.vars || [];
editor.declare.imports = editTree.data.declare.imports || [];
refresh();
}
onChange();
}
};
const selectNode = (id: string | null) => {
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.onEditingNode({
data: b3util.createNode(data, false),
editable: !isSubtreeNode(data),
limit_error: !b3util.checkChildrenLimit(data),
});
setItemState(editor.selectedId, "selected", true);
} else {
workspace.onEditingTree(editor);
}
};
const restoreViewport = () => {
if (editor.graphMatrix) {
editor.graph.getGroup().setMatrix(editor.graphMatrix);
}
};
const pushHistory = () => {
editor.historyStack.length = ++editor.historyIndex;
editor.historyStack.push(b3util.createNode(editor.data));
};
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 = createTreeData(JSON.parse(str), editor.selectedId);
editor.autoId = b3util.refreshTreeDataId(data, editor.autoId);
selectNode(null);
curNodeData.children ||= [];
curNodeData.children.push(data);
refreshItem(curNodeData);
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 = createTreeData(JSON.parse(str), editor.selectedId);
editor.autoId = b3util.refreshTreeDataId(data, editor.autoId);
parentData.children![idx] = data;
refreshItem(parentData);
updateGrahp();
} else {
editor.data = createTreeData(JSON.parse(str), editor.selectedId);
editor.autoId = b3util.refreshTreeDataId(editor.data, editor.firstid);
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 (filterOption.isFocused) {
return;
}
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(createTreeData(newNodeData, editor.selectedId));
refreshItem(curNodeData);
updateGrahp();
pushHistory();
onChange();
};
const deleteNode = () => {
if (!editor.selectedId) {
return;
}
if (editor.selectedId === "1") {
message.warning(t("node.deleteRootNodeDenied"));
return;
}
const data = findDataById(editor.selectedId);
if (isSubtreeNode(data) && !data.path) {
message.warning(t("node.editSubtreeDenied"));
return;
}
const parentData = findParent(data)!;
parentData.children = parentData.children!.filter((e) => e.id !== editor.selectedId);
refreshItem(parentData);
selectNode(null);
updateGrahp();
pushHistory();
onChange();
};
const refreshItem = (data: TreeGraphData) => {
editor.graph.findById(data.id)?.draw();
};
const useStackData = (data: NodeModel) => {
editor.data = createTreeData(data);
editor.autoId = b3util.refreshTreeDataId(editor.data, editor.firstid);
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);
updateSearchState();
}
};
const redo = () => {
if (editor.historyIndex < editor.historyStack.length - 1) {
const data = editor.historyStack[++editor.historyIndex];
useStackData(data);
updateSearchState();
}
};
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;
editor.autoId = b3util.refreshTreeDataId(editor.data, editor.firstid);
const root = b3util.createFileData(editor.data);
const treeModel = {
name: Path.basenameWithoutExt(path),
desc: editor.desc,
firstid: editor.firstid ?? 1,
export: editor.export,
group: editor.group ?? [],
declare: {
imports: editor.declare.imports.map((v) => v.path).sort(),
vars: editor.declare.vars.sort((a, b) => a.name.localeCompare(b.name)),
},
root,
} as TreeModel;
editor.modifiedTime = Date.now();
fs.writeFileSync(path, JSON.stringify(treeModel, null, 2));
workspace.updateFileMeta(editor);
editor.unsave = false;
editor.graph.changeData(editor.data);
editor.graph.layout();
restoreViewport();
updateSearchState();
updateState();
}
};
const editSubtree = () => {
if (!editor.selectedId) {
message.warning(t("node.noNodeSelected"));
return;
}
const data = findSubtree(findDataById(editor.selectedId));
if (data?.path) {
const path = `${workspace.workdir}/${data.path}`;
workspace.open(path, Number(editor.selectedId) - Number(data.id) + 1);
}
};
const saveAsSubtree = () => {
if (!editor.selectedId) {
message.warning(t("node.noNodeSelected"));
return;
}
if (editor.selectedId === "1") {
message.warning(t("node.saveAsSubtree"));
return;
}
setTimeout(() => {
let subpath = dialog.showSaveDialogSync({
defaultPath: workspace.workdir,
properties: ["showOverwriteConfirmation"],
filters: [{ name: "Json", extensions: ["json"] }],
});
if (!subpath) {
return;
}
subpath = subpath.replaceAll(Path.sep, "/");
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 = workspace.relative(subpath);
refresh();
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 160;
},
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(editor.data, highlight);
if (changed.length > 0) {
const refreshHighlight = (node: TreeGraphData) => {
const item = editor.graph.findById(node.id);
if (highlight.length > 0) {
node.highlightGray = !(
node.highlightInput ||
node.highlightOutput ||
node.highlightArgs
);
} else {
node.highlightGray = false;
}
item.draw();
if (node.children) {
node.children.forEach(refreshHighlight);
}
};
refreshHighlight(editor.data);
}
if (highlight.length === 0 && editor.searchingText) {
onSearchChange({
...filterOption,
filterType: "content",
filterStr: editor.searchingText,
});
}
});
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 dstNode = e.item;
let srcNodeId = editor.dragSrcId;
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 (e.originalEvent instanceof DragEvent) {
const dragEvent = e.originalEvent as DragEvent;
const exploreFile = dragEvent.dataTransfer?.getData("explore-file");
const exploreNode = dragEvent.dataTransfer?.getData("explore-node");
const dstData = findDataById(dstNode.getID());
if (exploreNode) {
const newTreeData: TreeGraphData = createTreeData(
{
id: editor.autoId++,
name: exploreNode,
},
dstData.id
);
dstData.children ||= [];
dstData.children.push(newTreeData);
refreshItem(dstData);
srcNodeId = newTreeData.id;
} else if (exploreFile && exploreFile !== editor.path) {
const newTreeData: TreeGraphData = createTreeData(
{
id: editor.autoId++,
name: "unknow",
path: workspace.relative(exploreFile),
},
dstData.id
);
dstData.children ||= [];
dstData.children.push(newTreeData);
refreshItem(dstData);
srcNodeId = newTreeData.id;
editor.autoId = b3util.refreshTreeDataId(newTreeData, Number(srcNodeId));
}
}
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;
}
refreshItem(srcParent);
refreshItem(dstData);
updateGrahp();
pushHistory();
onChange();
});
refreshTreeData(editor.data);
editor.graph.data(editor.data);
editor.graph.render();
if (editor.graphMatrix) {
restoreViewport();
} else {
editor.graph.fitCenter();
}
};
const keysRef = useRef<HTMLDivElement>(null);
useKeyDown(Hotkey.Copy, keysRef, () => copyNode());
useKeyDown(Hotkey.Replace, keysRef, () => replaceNode());
useKeyDown(Hotkey.Paste, keysRef, () => pasteNode());
useKeyDown(Hotkey.Undo, keysRef, () => undo());
useKeyDown(Hotkey.Redo, keysRef, () => redo());
useKeyDown([Hotkey.Insert, Hotkey.Enter], keysRef, () => createNode());
useKeyDown([Hotkey.Delete, Hotkey.Backspace], keysRef, () => deleteNode());
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 "refresh": {
refresh();
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 "searchNode": {
searchByType("content");
break;
}
case "jumpNode": {
searchByType("id");
break;
}
case "editSubtree": {
editSubtree();
break;
}
case "saveAsSubtree": {
saveAsSubtree();
break;
}
}
};
// check should rebuild graph
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;
}
if (typeof editor.editNode === "number") {
const id = editor.editNode.toString();
selectNode(null);
setTimeout(() => {
editor.graph.focusItem(id);
selectNode(id);
}, 100);
}
});
// check should subtree
useEffect(() => {
if (workspace.editing === editor) {
checkSubtree();
}
}, [workspace.editing]);
// check should repaint node
useEffect(() => {
if (editor.graph) {
editor.graph.changeData(editor.data);
editor.graph.layout();
restoreViewport();
}
}, [t]);
const nextResult = () => {
const { results, index } = filterOption;
if (results.length > 0) {
const idx = (index + 1) % results.length;
recursiveOpenCollapsed(results[idx]);
editor.graph.focusItem(results[idx]);
selectNode(results[idx]);
setFilterOption({ ...filterOption, index: idx });
}
};
const prevResult = () => {
const { results, index } = filterOption;
if (results.length > 0) {
const idx = (index + results.length - 1) % results.length;
recursiveOpenCollapsed(results[idx]);
editor.graph.focusItem(results[idx]);
selectNode(results[idx]);
setFilterOption({ ...filterOption, index: idx });
}
};
const searchByType = (type: FilterOption["filterType"]) => {
let placeholder = "";
const filterType = type;
// todo multiple parameter format judgment
switch (type) {
case "id":
placeholder = t("jumpNode");
break;
default:
placeholder = t("searchNode");
break;
}
if (!showingSearch) {
setFilterOption({ ...filterOption, placeholder, filterType });
setShowingSearch(true);
return;
}
if (filterOption.filterType === type) {
return searchInputRef.current?.focus();
}
setShowingSearch(false);
setTimeout(() => {
setShowingSearch(true);
setFilterOption({ ...filterOption, placeholder, filterType });
searchInputRef.current?.focus();
}, 50);
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.code === Hotkey.Enter) {
nextResult();
} else if ((e.ctrlKey || e.metaKey) && e.code === "KeyF") {
searchByType("content");
} else if ((e.ctrlKey || e.metaKey) && e.code === "KeyG") {
searchByType("id");
}
e.stopPropagation();
};
const recursiveOpenCollapsed = (id: string) => {
const recursiveFun = (searchId: string) => {
const data = findDataById(searchId);
if (searchId !== id && data.collapsed) {
data.collapsed = undefined;
}
if (data.parent) {
recursiveFun(data.parent);
}
};
recursiveFun(id);
updateGrahp();
};
return (
<div
{...props}
className="b3-editor"
ref={mergeRefs([sizeRef, keysRef])}
tabIndex={-1}
style={{ maxWidth: "inherit", maxHeight: "inherit" }}
>
{showingSearch && (
<Flex
style={{
position: "absolute",
width: "100%",
justifyContent: "end",
paddingRight: "10px",
paddingTop: "10px",
}}
>
<Flex
style={{
backgroundColor: "#161b22",
padding: "4px 10px 4px 10px",
borderRadius: "4px",
borderLeft: "3px solid #f78166",
boxShadow: "0 0 8px 2px #0000005c",
alignItems: "center",
}}
>
<Input
ref={searchInputRef}
placeholder={filterOption.placeholder}
autoFocus
size="small"
style={{
borderRadius: "2px",
paddingTop: "1px",
paddingBottom: "1px",
paddingRight: "2px",
}}
onChange={(e) =>
onDebounceSearchChange({
...filterOption,
filterStr: e.currentTarget.value,
index: 0,
})
}
onFocus={() => setFilterOption({ ...filterOption, isFocused: true })}
onBlur={() => setFilterOption({ ...filterOption, isFocused: false })}
onKeyDownCapture={handleKeyDown}
suffix={
<Flex gap="2px" style={{ alignItems: "center" }}>
{filterOption.filterType !== "id" && (
<Button
type="text"
size="small"
className={mergeClassNames(
"b3-editor-filter",
filterOption.filterCase && "b3-editor-filter-selected"
)}
icon={<VscCaseSensitive style={{ width: "18px", height: "18px" }} />}
onClick={() =>
onSearchChange({
...filterOption,
filterCase: !filterOption.filterCase,
})
}
/>
)}
<Button
type="text"
size="small"
className={mergeClassNames(
"b3-editor-filter",
filterOption.filterFocus && "b3-editor-filter-selected"
)}
icon={<RiFocus3Line />}
onClick={() =>
onSearchChange({
...filterOption,
filterFocus: !filterOption.filterFocus,
})
}
/>
</Flex>
}
/>
<div style={{ padding: "0 10px 0 5px", minWidth: "40px" }}>
{filterOption.results.length
? `${filterOption.index + 1}/${filterOption.results.length}`
: ""}
</div>
{filterOption.filterType !== "id" && (
<Button
icon={<ArrowDownOutlined />}
type="text"
size="small"
style={{ width: "30px" }}
disabled={filterOption.results.length === 0}
onClick={nextResult}
/>
)}
{filterOption.filterType !== "id" && (
<Button
icon={<ArrowUpOutlined />}
type="text"
size="small"
style={{ width: "30px" }}
disabled={filterOption.results.length === 0}
onClick={prevResult}
/>
)}
<Button
icon={<CloseOutlined />}
type="text"
size="small"
style={{ width: "30px" }}
onClick={() => {
setShowingSearch(false);
onSearchChange({
results: [],
index: 0,
filterCase: false,
filterFocus: true,
filterStr: "",
filterType: "content",
placeholder: "",
isFocused: false,
});
keysRef.current?.focus();
}}
/>
</Flex>
</Flex>
)}
<Dropdown
menu={{ items: menuItems, onClick: (info) => editor.dispatch(info.key as EditEvent) }}
trigger={["contextMenu"]}
>
<div tabIndex={-1} style={{ width: "100%", height: "100%" }} ref={graphRef} />
</Dropdown>
</div>
);
};