2025-01-18 18:37:01 +08:00

325 lines
9.4 KiB
TypeScript

import { memo, useRef } from "react";
import { useTranslation } from "react-i18next";
import {
CloseCircleOutlined as CloseCircleOutlinedIcon,
EllipsisOutlined as EllipsisOutlinedIcon,
FormOutlined as FormOutlinedIcon,
MoreOutlined as MoreOutlinedIcon,
} from "@ant-design/icons";
import { useControllableValue } from "ahooks";
import { Button, Card, Drawer, Dropdown, Input, type InputRef, Modal, Popover, Space } from "antd";
import { produce } from "immer";
import { isEqual } from "radash";
import { type WorkflowNode, WorkflowNodeType } from "@/domain/workflow";
import { useZustandShallowSelector } from "@/hooks";
import { useWorkflowStore } from "@/stores/workflow";
import AddNode from "./AddNode";
export type SharedNodeProps = {
node: WorkflowNode;
disabled?: boolean;
};
// #region Title
type SharedNodeTitleProps = SharedNodeProps & {
className?: string;
style?: React.CSSProperties;
};
const SharedNodeTitle = ({ className, style, node, disabled }: SharedNodeTitleProps) => {
const { updateNode } = useWorkflowStore(useZustandShallowSelector(["updateNode"]));
const handleBlur = (e: React.FocusEvent<HTMLDivElement>) => {
const oldName = node.name;
const newName = e.target.innerText.trim().substring(0, 64) || oldName;
if (oldName === newName) {
return;
}
updateNode(
produce(node, (draft) => {
draft.name = newName;
})
);
};
return (
<div className="w-full cursor-text overflow-hidden text-center">
<div className={className} style={style} contentEditable={!disabled} suppressContentEditableWarning onBlur={handleBlur}>
{node.name}
</div>
</div>
);
};
// #endregion
// #region Menu
type SharedNodeMenuProps = SharedNodeProps & {
branchId?: string;
branchIndex?: number;
trigger: React.ReactNode;
afterUpdate?: () => void;
afterDelete?: () => void;
};
const SharedNodeMenu = ({ trigger, node, disabled, branchId, branchIndex, afterUpdate, afterDelete }: SharedNodeMenuProps) => {
const { t } = useTranslation();
const { updateNode, removeNode, removeBranch } = useWorkflowStore(useZustandShallowSelector(["updateNode", "removeNode", "removeBranch"]));
const [modalApi, ModelContextHolder] = Modal.useModal();
const nameInputRef = useRef<InputRef>(null);
const nameRef = useRef<string>();
const handleRenameConfirm = async () => {
const oldName = node.name;
const newName = nameRef.current?.trim()?.substring(0, 64) || oldName;
if (oldName === newName) {
return;
}
await updateNode(
produce(node, (draft) => {
draft.name = newName;
})
);
afterUpdate?.();
};
const handleDeleteClick = async () => {
if (node.type === WorkflowNodeType.Branch || node.type === WorkflowNodeType.Condition) {
await removeBranch(branchId!, branchIndex!);
} else {
await removeNode(node.id);
}
afterDelete?.();
};
return (
<>
{ModelContextHolder}
<Dropdown
menu={{
items: [
{
key: "rename",
disabled: disabled,
label:
node.type === WorkflowNodeType.Branch || node.type === WorkflowNodeType.Condition
? t("workflow_node.action.rename_branch")
: t("workflow_node.action.rename_node"),
icon: <FormOutlinedIcon />,
onClick: () => {
nameRef.current = node.name;
const dialog = modalApi.confirm({
title:
node.type === WorkflowNodeType.Branch || node.type === WorkflowNodeType.Condition
? t("workflow_node.action.rename_branch")
: t("workflow_node.action.rename_node"),
content: (
<div className="pb-2 pt-4">
<Input
ref={nameInputRef}
autoFocus
defaultValue={node.name}
onChange={(e) => (nameRef.current = e.target.value)}
onPressEnter={async () => {
await handleRenameConfirm();
dialog.destroy();
}}
/>
</div>
),
icon: null,
okText: t("common.button.save"),
onOk: handleRenameConfirm,
});
setTimeout(() => nameInputRef.current?.focus(), 1);
},
},
{
type: "divider",
},
{
key: "remove",
disabled: disabled || node.type === WorkflowNodeType.Start,
label:
node.type === WorkflowNodeType.Branch || node.type === WorkflowNodeType.Condition
? t("workflow_node.action.remove_branch")
: t("workflow_node.action.remove_node"),
icon: <CloseCircleOutlinedIcon />,
danger: true,
onClick: handleDeleteClick,
},
],
}}
trigger={["click"]}
>
{trigger}
</Dropdown>
</>
);
};
// #endregion
// #region Wrapper
type SharedNodeBlockProps = SharedNodeProps & {
children: React.ReactNode;
onClick?: (e: React.MouseEvent) => void;
};
const SharedNodeBlock = ({ children, node, disabled, onClick }: SharedNodeBlockProps) => {
const handleNodeClick = (e: React.MouseEvent) => {
onClick?.(e);
};
return (
<>
<Popover
arrow={false}
content={<SharedNodeMenu node={node} disabled={disabled} trigger={<Button color="primary" icon={<MoreOutlinedIcon />} variant="text" />} />}
overlayClassName="shadow-md"
overlayInnerStyle={{ padding: 0 }}
placement="rightTop"
>
<Card className="relative w-[256px] overflow-hidden shadow-md" styles={{ body: { padding: 0 } }} hoverable>
<div className="bg-primary flex h-[48px] flex-col items-center justify-center truncate px-4 py-2 text-white">
<SharedNodeTitle
className="focus:bg-background focus:text-foreground overflow-hidden outline-none focus:rounded-sm"
node={node}
disabled={disabled}
/>
</div>
<div className="flex cursor-pointer flex-col justify-center px-4 py-2" onClick={handleNodeClick}>
<div className="overflow-hidden text-sm">{children}</div>
</div>
</Card>
</Popover>
<AddNode node={node} disabled={disabled} />
</>
);
};
// #endregion
// #region EditDrawer
type SharedNodeEditDrawerProps = SharedNodeProps & {
children: React.ReactNode;
footer?: boolean;
loading?: boolean;
open?: boolean;
pending?: boolean;
onOpenChange?: (open: boolean) => void;
onConfirm: () => void | Promise<unknown>;
getFormValues: () => NonNullable<unknown>;
};
const SharedNodeConfigDrawer = ({
children,
node,
disabled,
footer = true,
loading,
pending,
onConfirm,
getFormValues,
...props
}: SharedNodeEditDrawerProps) => {
const { t } = useTranslation();
const [modalApi, ModelContextHolder] = Modal.useModal();
const [open, setOpen] = useControllableValue<boolean>(props, {
valuePropName: "open",
defaultValuePropName: "defaultOpen",
trigger: "onOpenChange",
});
const handleConfirmClick = async () => {
await onConfirm();
setOpen(false);
};
const handleCancelClick = () => {
if (pending) return;
setOpen(false);
};
const handleClose = () => {
if (pending) return;
const oldValues = Object.fromEntries(Object.entries(node.config ?? {}).filter(([_, value]) => value !== null && value !== undefined));
const newValues = Object.fromEntries(Object.entries(getFormValues()).filter(([_, value]) => value !== null && value !== undefined));
const changed = !isEqual(oldValues, newValues);
const { promise, resolve, reject } = Promise.withResolvers();
if (changed) {
modalApi.confirm({
title: t("common.text.operation_confirm"),
content: t("workflow_node.unsaved_changes.confirm"),
onOk: () => resolve(void 0),
onCancel: () => reject(),
});
} else {
resolve(void 0);
}
promise.then(() => setOpen(false));
};
return (
<>
{ModelContextHolder}
<Drawer
afterOpenChange={(open) => setOpen(open)}
destroyOnClose
extra={
<SharedNodeMenu
node={node}
disabled={disabled}
trigger={<Button icon={<EllipsisOutlinedIcon />} type="text" />}
afterDelete={() => {
setOpen(false);
}}
/>
}
footer={
!!footer && (
<Space className="w-full justify-end">
<Button onClick={handleCancelClick}>{t("common.button.cancel")}</Button>
<Button disabled={disabled} loading={pending} type="primary" onClick={handleConfirmClick}>
{t("common.button.save")}
</Button>
</Space>
)
}
loading={loading}
open={open}
title={<div className="max-w-[480px] truncate">{node.name}</div>}
width={640}
onClose={handleClose}
>
{children}
</Drawer>
</>
);
};
// #endregion
export default {
Title: memo(SharedNodeTitle),
Menu: memo(SharedNodeMenu),
Block: memo(SharedNodeBlock),
ConfigDrawer: memo(SharedNodeConfigDrawer),
};