feat(ui): add onDocumentChange handler in WorkflowDesigner

This commit is contained in:
Fu Diwei 2025-08-13 20:59:21 +08:00
parent f440103fce
commit c7b88cbcfb
6 changed files with 186 additions and 143 deletions

View File

@ -1,4 +1,4 @@
import { forwardRef, useImperativeHandle, useMemo, useRef } from "react";
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from "react";
import {
ConstantKeys,
EditorRenderer,
@ -27,6 +27,8 @@ export interface DesignerProps {
children?: React.ReactNode;
initialData?: FlowDocumentJSON;
readonly?: boolean;
onDocumentChange?: (ctx: FixedLayoutPluginContext) => void;
onNodeChange?: (ctx: FixedLayoutPluginContext, node: FlowNodeEntity) => void;
onNodeClick?: (ctx: FixedLayoutPluginContext, node: FlowNodeEntity) => void;
}
@ -35,149 +37,180 @@ export interface DesignerInstance extends FixedLayoutPluginContext {
validateAllNodes(): Promise<boolean>;
}
const Designer = forwardRef<DesignerInstance, DesignerProps>(({ className, style, children, initialData, readonly, onNodeClick }, ref) => {
const { token: themeToken } = theme.useToken();
const Designer = forwardRef<DesignerInstance, DesignerProps>(
({ className, style, children, initialData, readonly, onDocumentChange, onNodeChange, onNodeClick }, ref) => {
const { token: themeToken } = theme.useToken();
const flowgramEditorRef = useRef<FixedLayoutPluginContext>(null);
const flowgramEditorProps = useMemo<FixedLayoutProps>(
() => ({
initialData: initialData,
const rendered = useRef(false);
constants: {
[ConstantKeys.BASE_COLOR]: themeToken.colorBorder,
[ConstantKeys.BASE_ACTIVATED_COLOR]: themeToken.colorPrimary,
[ConstantKeys.NODE_SPACING]: 48,
[ConstantKeys.BRANCH_SPACING]: 48,
},
const flowgramEditorRef = useRef<FixedLayoutPluginContext>(null);
const flowgramEditorProps = useMemo<FixedLayoutProps>(
() => ({
initialData: initialData,
background: {
backgroundColor: themeToken.colorBgContainer,
dotSize: 0,
},
playground: {
autoFocus: true,
autoResize: true,
preventGlobalGesture: true,
},
selectBox: {
enable: false,
},
scroll: {
enableScrollLimit: true,
},
readonly: readonly,
nodeEngine: {
enable: true,
},
variableEngine: {
enable: true,
},
materials: {
components: getAllElements(),
renderTexts: {
[FlowTextKey.TRY_START_TEXT]: "Try",
[FlowTextKey.TRY_END_TEXT]: "Then",
[FlowTextKey.CATCH_TEXT]: "Catch",
constants: {
[ConstantKeys.BASE_COLOR]: themeToken.colorBorder,
[ConstantKeys.BASE_ACTIVATED_COLOR]: themeToken.colorPrimary,
[ConstantKeys.NODE_SPACING]: 48,
[ConstantKeys.BRANCH_SPACING]: 48,
},
renderDefaultNode: NodeRender,
},
nodeRegistries: getAllNodeRegistries(),
background: {
backgroundColor: themeToken.colorBgContainer,
dotSize: 0,
},
getNodeDefaultRegistry(type) {
return {
type,
meta: {
defaultExpanded: true,
playground: {
autoFocus: true,
autoResize: true,
preventGlobalGesture: true,
},
selectBox: {
enable: false,
},
scroll: {
enableScrollLimit: true,
},
readonly: readonly,
nodeEngine: {
enable: true,
},
variableEngine: {
enable: true,
},
materials: {
components: getAllElements(),
renderTexts: {
[FlowTextKey.TRY_START_TEXT]: "Try",
[FlowTextKey.TRY_END_TEXT]: "Then",
[FlowTextKey.CATCH_TEXT]: "Catch",
},
formMeta: {
render: () => <BranchNode description={type} />,
},
};
},
renderDefaultNode: NodeRender,
},
plugins: () => [
createMinimapPlugin({
disableLayer: true,
enableDisplayAllNodes: true,
canvasStyle: {
canvasWidth: 160,
canvasHeight: 160,
},
}),
],
nodeRegistries: getAllNodeRegistries(),
onAllLayersRendered: (ctx) => {
// 画布初始化后向下滚动一点,露出可能被 Alert 遮挡的部分
setTimeout(() => {
ctx.playground.config.scroll({ scrollY: -80 });
}, 1);
},
}),
[themeToken, initialData, readonly]
);
getNodeDefaultRegistry(type) {
return {
type,
meta: {
defaultExpanded: true,
},
formMeta: {
render: () => <BranchNode description={type} />,
},
};
},
useImperativeHandle(ref, () => {
return {
get container() {
return flowgramEditorRef.current!.container;
},
get document() {
return flowgramEditorRef.current!.document;
},
get playground() {
return flowgramEditorRef.current!.playground;
},
get operation() {
return flowgramEditorRef.current!.operation;
},
get clipboard() {
return flowgramEditorRef.current!.clipboard;
},
get selection() {
return flowgramEditorRef.current!.selection;
},
get history() {
return flowgramEditorRef.current!.history;
},
plugins: () => [
createMinimapPlugin({
disableLayer: true,
enableDisplayAllNodes: true,
canvasStyle: {
canvasWidth: 160,
canvasHeight: 160,
},
}),
],
get(identifier) {
return flowgramEditorRef.current!.get(identifier);
},
getAll(identifier) {
return flowgramEditorRef.current!.getAll(identifier);
},
validateNode(node) {
if (typeof node === "string") {
node = flowgramEditorRef.current!.document.getNode(node)!;
onAllLayersRendered: (ctx) => {
rendered.current = true;
// 画布初始化后向下滚动一点,露出可能被 Alert 遮挡的部分
setTimeout(() => {
ctx.playground.config.scroll({ scrollY: -80 });
}, 1);
},
}),
[themeToken, initialData, readonly]
);
useEffect(() => {
const callback = () => {
if (rendered.current) {
onDocumentChange?.(flowgramEditorRef.current!);
}
};
const d1 = flowgramEditorRef.current?.document?.onNodeCreate(callback);
const d2 = flowgramEditorRef.current?.document?.onNodeUpdate(callback);
const d3 = flowgramEditorRef.current?.document?.onNodeDispose(callback);
const d4 = flowgramEditorRef.current?.document?.renderTree?.onTreeChange(callback);
const form = getNodeForm(node);
return form ? form.validate().then((res) => res && !form.state.invalid) : Promise.resolve(true);
},
validateAllNodes() {
const nodes = flowgramEditorRef.current!.document.getAllNodes();
const forms = nodes.map((node) => getNodeForm(node)).filter((form) => form != null);
return Promise.allSettled(forms.map((form) => form.validate())).then((res) => forms.every((form, index) => res[index] && !form.state.invalid));
},
};
});
return () => {
d1?.dispose();
d2?.dispose();
d3?.dispose();
d4?.dispose();
};
}, [onDocumentChange]);
return (
<FixedLayoutEditorProvider ref={flowgramEditorRef} {...flowgramEditorProps}>
<DegisnerContextProvider value={{ onNodeClick: (node) => onNodeClick?.(flowgramEditorRef.current!, node) }}>
<EditorRenderer className={className} style={style} />
{children}
</DegisnerContextProvider>
</FixedLayoutEditorProvider>
);
});
useImperativeHandle(ref, () => {
return {
get container() {
return flowgramEditorRef.current!.container;
},
get document() {
return flowgramEditorRef.current!.document;
},
get playground() {
return flowgramEditorRef.current!.playground;
},
get operation() {
return flowgramEditorRef.current!.operation;
},
get clipboard() {
return flowgramEditorRef.current!.clipboard;
},
get selection() {
return flowgramEditorRef.current!.selection;
},
get history() {
return flowgramEditorRef.current!.history;
},
get(identifier) {
return flowgramEditorRef.current!.get(identifier);
},
getAll(identifier) {
return flowgramEditorRef.current!.getAll(identifier);
},
validateNode(node) {
if (typeof node === "string") {
node = flowgramEditorRef.current!.document.getNode(node)!;
}
const form = getNodeForm(node);
return form ? form.validate().then((res) => res && !form.state.invalid) : Promise.resolve(true);
},
validateAllNodes() {
const nodes = flowgramEditorRef.current!.document.getAllNodes();
const forms = nodes.map((node) => getNodeForm(node)).filter((form) => form != null);
return Promise.allSettled(forms.map((form) => form.validate())).then((res) => forms.every((form, index) => res[index] && !form.state.invalid));
},
};
});
return (
<FixedLayoutEditorProvider ref={flowgramEditorRef} {...flowgramEditorProps}>
<DegisnerContextProvider
value={{
onDocumentChange: () => onDocumentChange?.(flowgramEditorRef.current!),
onNodeChange: (node) => onNodeChange?.(flowgramEditorRef.current!, node),
onNodeClick: (node) => onNodeClick?.(flowgramEditorRef.current!, node),
}}
>
<EditorRenderer className={className} style={style} />
{children}
</DegisnerContextProvider>
</FixedLayoutEditorProvider>
);
}
);
export default Designer;

View File

@ -2,10 +2,14 @@
import { type FlowNodeEntity } from "@flowgram.ai/fixed-layout-editor";
export type DesignerContextType = {
onDocumentChange: () => void;
onNodeChange: (node: FlowNodeEntity) => void;
onNodeClick: (node: FlowNodeEntity) => void;
};
export const DesignerContext = createContext<DesignerContextType>({
onDocumentChange: () => {},
onNodeChange: () => {},
onNodeClick: () => {},
});

View File

@ -14,16 +14,25 @@ const Node = (_: NodeProps) => {
const nodeRender = useNodeRender();
const designer = useDesignerContext();
const { onDocumentChange: fireOnDocumentChange, onNodeChange: fireOnNodeChange, onNodeClick: fireOnNodeClick } = useDesignerContext();
useEffect(() => {
const d = ctx.document.originTree.onTreeChange(() => refresh());
return () => d.dispose();
}, []);
useEffect(() => {
const d1 = nodeRender.form?.onFormValuesChange?.(() => refresh());
const d2 = nodeRender.form?.onValidate?.(() => refresh());
const d1 = nodeRender.form?.onFormValuesChange?.(() => {
refresh();
fireOnNodeChange(nodeRender.node);
fireOnDocumentChange();
});
const d2 = nodeRender.form?.onValidate?.(() => {
refresh();
});
return () => {
d1?.dispose();
d2?.dispose();
@ -33,7 +42,7 @@ const Node = (_: NodeProps) => {
const handleNodeClick = () => {
const node = nodeRender.node;
if (node.getNodeRegistry<NodeRegistry>().meta?.clickable) {
designer.onNodeClick?.(node);
fireOnNodeClick(node);
}
};

View File

@ -25,6 +25,7 @@ const Toolbar = ({ className, style }: ToolbarProps) => {
useEffect(() => {
const d = playground.config.onReadonlyOrDisabledChange(() => refresh());
return () => d.dispose();
}, [playground]);

View File

@ -150,6 +150,7 @@ const InternalNodeMenuButton = ({
};
const d1 = node.onEntityChange(callback);
const d2 = node.parent?.onEntityChange?.(callback);
return () => {
d1?.dispose();
d2?.dispose();

View File

@ -55,12 +55,7 @@ const WorkflowDetailDesign = () => {
console.log("document changed", designerRef.current!.document.toJSON());
});
useEffect(() => {
const disposable = designerRef.current?.document?.originTree?.onTreeChange(onDesignerDocumentChange);
return () => disposable?.dispose();
}, []);
const { setNode: setDrawerNode, setOpen: setNodeDrawerOpen, ...nodeDrawerProps } = WorkflowNodeDrawer.useProps();
const { drawerProps: nodeDrawerProps, ...nodeDrawer } = WorkflowNodeDrawer.useDrawer();
const handleRollbackClick = () => {
modal.confirm({
@ -116,9 +111,9 @@ const WorkflowDetailDesign = () => {
<WorkflowDesigner
ref={designerRef}
initialData={degisnerData}
onDocumentChange={onDesignerDocumentChange}
onNodeClick={(_, node) => {
setDrawerNode(node);
setNodeDrawerOpen(true);
nodeDrawer.open(node);
}}
>
<div className="absolute top-8 z-10 w-full px-4">