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