Merge pull request #521 from SuanmoSuanyangTechnology/feature/notes_zy
feat(web): 注释节点
This commit is contained in:
@@ -2040,6 +2040,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
||||
self_optimization: 'Self Optimization',
|
||||
process_evolution: 'Process Evolution',
|
||||
unknown: 'Unknown Node',
|
||||
notes: 'Sticky Note',
|
||||
|
||||
clickToConfigure: 'Click to configure node parameters',
|
||||
nodeProperties: 'Node Properties',
|
||||
@@ -2227,6 +2228,12 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
||||
output_variables: 'Output Variables',
|
||||
refreshTip: 'Sync function signature to code',
|
||||
},
|
||||
notes: {
|
||||
showAuth: 'Show Author',
|
||||
enterLink: 'Enter Link URL',
|
||||
placeholder: 'Enter note...',
|
||||
removeLink: 'Remove Link',
|
||||
},
|
||||
name: 'Key',
|
||||
type: 'Type',
|
||||
value: 'Value',
|
||||
|
||||
@@ -2036,6 +2036,7 @@ export const zh = {
|
||||
self_optimization: '自我优化',
|
||||
process_evolution: '流程演化',
|
||||
unknown: '未知节点',
|
||||
notes: '便签',
|
||||
|
||||
clickToConfigure: '点击配置节点参数',
|
||||
nodeProperties: '节点属性',
|
||||
@@ -2226,6 +2227,12 @@ export const zh = {
|
||||
unknown: {
|
||||
replaceNodeType: '替换节点'
|
||||
},
|
||||
notes: {
|
||||
showAuth: '显示作者',
|
||||
enterLink: '输入链接 URL',
|
||||
placeholder: '输入注释...',
|
||||
removeLink: '取消链接',
|
||||
},
|
||||
name: '键',
|
||||
type: '类型',
|
||||
value: '值',
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { FC } from 'react';
|
||||
import { Select } from 'antd';
|
||||
import { Select, Divider } from 'antd';
|
||||
// import { Node } from '@antv/x6';
|
||||
import type { GraphRef } from '../types'
|
||||
import { PlusOutlined, MinusOutlined } from '@ant-design/icons'
|
||||
import { PlusOutlined, MinusOutlined, FileAddOutlined } from '@ant-design/icons'
|
||||
|
||||
interface CanvasToolbarProps {
|
||||
miniMapRef: React.RefObject<HTMLDivElement>;
|
||||
@@ -14,6 +14,7 @@ interface CanvasToolbarProps {
|
||||
canRedo: boolean;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
addNotes: () => void;
|
||||
}
|
||||
|
||||
const CanvasToolbar: FC<CanvasToolbarProps> = ({
|
||||
@@ -26,6 +27,7 @@ const CanvasToolbar: FC<CanvasToolbarProps> = ({
|
||||
// canRedo,
|
||||
// onUndo,
|
||||
// onRedo,
|
||||
addNotes,
|
||||
}) => {
|
||||
// 整理布局函数
|
||||
/*
|
||||
@@ -152,7 +154,7 @@ const CanvasToolbar: FC<CanvasToolbarProps> = ({
|
||||
{/* 小地图 */}
|
||||
<div ref={miniMapRef} className="rb:absolute rb:bottom-15 rb:right-8 rb:z-1000 rb:rounded-lg rb:overflow-hidden"></div>
|
||||
{/* 缩放控制按钮 */}
|
||||
<div className="rb:h-8.5 rb:bg-[#FFFFFF] rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:shadow-[0px_2px_6px_0px_rgba(33,35,50,0.15)] rb:px-3 rb:py-2 rb:absolute rb:bottom-5 rb:right-8 rb:flex rb:flex-row rb:gap-4 rb:z-1000">
|
||||
<div className="rb:h-8.5 rb:bg-[#FFFFFF] rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:shadow-[0px_2px_6px_0px_rgba(33,35,50,0.15)] rb:px-3 rb:py-2 rb:absolute rb:bottom-5 rb:right-8 rb:flex rb:flex-row rb:items-center rb:gap-4 rb:z-1000">
|
||||
<MinusOutlined className="rb:text-[16px] rb:cursor-pointer" onClick={() => graphRef.current?.zoom(-0.1)} />
|
||||
<Select
|
||||
value={Math.round(zoomLevel * 100)}
|
||||
@@ -182,6 +184,8 @@ const CanvasToolbar: FC<CanvasToolbarProps> = ({
|
||||
size="small"
|
||||
/>
|
||||
<PlusOutlined className="rb:text-[16px] rb:cursor-pointer" onClick={() => graphRef.current?.zoom(0.1)} />
|
||||
<Divider type="vertical" className="rb:h-4" />
|
||||
<FileAddOutlined onClick={addNotes} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
import { FORMAT_TEXT_COMMAND, $getSelection, $isRangeSelection, $setSelection, $isTextNode, type BaseSelection } from 'lexical';
|
||||
import { $patchStyleText } from '@lexical/selection';
|
||||
import { INSERT_UNORDERED_LIST_COMMAND, REMOVE_LIST_COMMAND, ListNode } from '@lexical/list';
|
||||
import { TOGGLE_LINK_COMMAND, LinkNode } from '@lexical/link';
|
||||
import { $getNearestNodeOfType } from '@lexical/utils';
|
||||
|
||||
export const NOTE_FORMAT_EVENT = 'note:format';
|
||||
|
||||
export interface FormatState {
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
strikethrough: boolean;
|
||||
list: boolean;
|
||||
fontSize?: number;
|
||||
linkUrl?: string | null;
|
||||
}
|
||||
|
||||
const NoteFormatPlugin = ({ nodeId, onFormatChange, fontSize = 12 }: { nodeId: string; fontSize?: number; onFormatChange?: (state: FormatState) => void }) => {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const savedSelection = useRef<BaseSelection | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerUpdateListener(({ editorState }) => {
|
||||
editorState.read(() => {
|
||||
const selection = $getSelection();
|
||||
if (!$isRangeSelection(selection)) return;
|
||||
savedSelection.current = selection.clone();
|
||||
const anchorNode = selection.anchor.getNode();
|
||||
const style = 'getStyle' in anchorNode ? (anchorNode as { getStyle(): string }).getStyle() : '';
|
||||
const match = style.match(/font-size:\s*([\d.]+)px/);
|
||||
const nodeFontSize = match ? Number(match[1]) : fontSize;
|
||||
const linkNode = $getNearestNodeOfType(anchorNode, LinkNode);
|
||||
onFormatChange?.({
|
||||
bold: selection.hasFormat('bold'),
|
||||
italic: selection.hasFormat('italic'),
|
||||
strikethrough: selection.hasFormat('strikethrough'),
|
||||
list: !!$getNearestNodeOfType(anchorNode, ListNode),
|
||||
...(nodeFontSize ? { fontSize: nodeFontSize } : {}),
|
||||
linkUrl: linkNode ? linkNode.getURL() : null,
|
||||
});
|
||||
});
|
||||
});
|
||||
}, [editor, onFormatChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const { id, format, value } = (e as CustomEvent).detail;
|
||||
if (id !== nodeId) return;
|
||||
const sel = savedSelection.current;
|
||||
const hasSelection = $isRangeSelection(sel) && !sel.isCollapsed();
|
||||
if (format === 'link' && value === null) {
|
||||
// remove link: select the entire LinkNode first
|
||||
editor.focus(() => {
|
||||
editor.update(() => {
|
||||
const s = $getSelection();
|
||||
const anchorNode = $isRangeSelection(s)
|
||||
? s.anchor.getNode()
|
||||
: savedSelection.current && $isRangeSelection(savedSelection.current)
|
||||
? savedSelection.current.anchor.getNode()
|
||||
: null;
|
||||
const linkNode = anchorNode ? $getNearestNodeOfType(anchorNode, LinkNode) : null;
|
||||
if (linkNode) {
|
||||
const children = linkNode.getChildren();
|
||||
if (children.length > 0) {
|
||||
const first = children[0];
|
||||
const last = children[children.length - 1];
|
||||
if ($isTextNode(first) && $isTextNode(last)) {
|
||||
const range = first.select(0, 0);
|
||||
range.focus.set(last.getKey(), last.getTextContentSize(), 'text');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
|
||||
});
|
||||
} else if (format === 'list') {
|
||||
editor.focus(() => {
|
||||
if (sel) editor.update(() => $setSelection(sel));
|
||||
editor.dispatchCommand(value ? INSERT_UNORDERED_LIST_COMMAND : REMOVE_LIST_COMMAND, undefined);
|
||||
editor.update(() => $setSelection(null));
|
||||
});
|
||||
} else if (hasSelection) {
|
||||
editor.focus(() => {
|
||||
editor.update(() => $setSelection(sel));
|
||||
if (format === 'bold' || format === 'italic' || format === 'strikethrough') {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, format);
|
||||
} else if (format === 'link') {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, value as string | null);
|
||||
} else if (format === 'fontSize') {
|
||||
editor.update(() => {
|
||||
$setSelection(sel);
|
||||
$patchStyleText(sel!, { 'font-size': `${value}px` });
|
||||
});
|
||||
}
|
||||
editor.update(() => $setSelection(null));
|
||||
});
|
||||
}
|
||||
};
|
||||
window.addEventListener(NOTE_FORMAT_EVENT, handler);
|
||||
return () => window.removeEventListener(NOTE_FORMAT_EVENT, handler);
|
||||
}, [editor, nodeId]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default NoteFormatPlugin;
|
||||
@@ -0,0 +1,74 @@
|
||||
import { type FC, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Flex, Button, Input } from 'antd';
|
||||
import { EditOutlined, DisconnectOutlined } from '@ant-design/icons';
|
||||
|
||||
const POPOVER_STYLE: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
zIndex: 1000,
|
||||
background: '#fff',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
|
||||
whiteSpace: 'nowrap',
|
||||
};
|
||||
|
||||
interface LinkPopoverProps {
|
||||
url: string;
|
||||
rect: DOMRect;
|
||||
onEdit: () => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
export const LinkPopover: FC<LinkPopoverProps> = ({ url, rect, onEdit, onRemove }) => {
|
||||
const { t } = useTranslation();
|
||||
return createPortal(
|
||||
<div
|
||||
style={{ ...POPOVER_STYLE, left: rect.left, top: rect.bottom + 4, padding: '4px 10px', fontSize: 12 }}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
>
|
||||
<Flex align="center" gap={8}>
|
||||
<a href={url} target="_blank" rel="noreferrer" style={{ color: '#2563eb', maxWidth: 160, overflow: 'hidden', textOverflow: 'ellipsis', display: 'inline-block' }}>
|
||||
{url}
|
||||
</a>
|
||||
<Button size="small" type="text" icon={<EditOutlined />} onClick={onEdit}>{t('common.edit')}</Button>
|
||||
<Button size="small" type="text" icon={<DisconnectOutlined />} onClick={onRemove}>{t('workflow.config.notes.removeLink')}</Button>
|
||||
</Flex>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
interface EditLinkPopoverProps {
|
||||
rect: DOMRect;
|
||||
initialUrl: string;
|
||||
onConfirm: (url: string) => void;
|
||||
}
|
||||
|
||||
export const EditLinkPopover: FC<EditLinkPopoverProps> = ({ rect, initialUrl, onConfirm }) => {
|
||||
const { t } = useTranslation();
|
||||
const [url, setUrl] = useState(initialUrl);
|
||||
const confirm = () => onConfirm(url);
|
||||
return createPortal(
|
||||
<div
|
||||
style={{ ...POPOVER_STYLE, left: rect.left, top: rect.bottom + 4, padding: '8px' }}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
>
|
||||
<Flex gap={8}>
|
||||
<Input
|
||||
size="small"
|
||||
className="rb:w-60!"
|
||||
placeholder={t('workflow.config.notes.enterLink')}
|
||||
value={url}
|
||||
onChange={e => setUrl(e.target.value)}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
onPressEnter={confirm}
|
||||
autoFocus
|
||||
/>
|
||||
<Button size="small" type="primary" onClick={confirm}>{t('common.confirm')}</Button>
|
||||
</Flex>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,184 @@
|
||||
import { type FC, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LexicalComposer } from '@lexical/react/LexicalComposer';
|
||||
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
|
||||
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
|
||||
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
|
||||
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
|
||||
import { ListPlugin } from '@lexical/react/LexicalListPlugin';
|
||||
import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin';
|
||||
import { ListNode, ListItemNode } from '@lexical/list';
|
||||
import { LinkNode } from '@lexical/link';
|
||||
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import NoteFormatPlugin from './NoteFormatPlugin';
|
||||
import type { FormatState } from './NoteFormatPlugin';
|
||||
import { LinkPopover, EditLinkPopover } from './NoteLinkPopovers';
|
||||
|
||||
const theme = {
|
||||
paragraph: 'editor-paragraph',
|
||||
text: {
|
||||
bold: 'editor-text-bold',
|
||||
italic: 'editor-text-italic',
|
||||
strikethrough: 'note-text-strikethrough',
|
||||
},
|
||||
list: { ul: 'note-list-ul', listitem: 'note-list-item' },
|
||||
link: 'note-link',
|
||||
};
|
||||
|
||||
const NOTE_NODES = [ListNode, ListItemNode, LinkNode];
|
||||
|
||||
const NOTE_STYLES = `
|
||||
.editor-text-bold { font-weight: bold; }
|
||||
.editor-text-italic { font-style: italic; }
|
||||
.note-text-strikethrough { text-decoration: line-through; }
|
||||
.note-list-ul { list-style-type: disc; padding-left: 1.2em; margin: 0; }
|
||||
.note-list-item { margin: 2px 0; }
|
||||
.note-link { color: #2563eb; text-decoration: underline; cursor: pointer; }
|
||||
`;
|
||||
|
||||
const NoteInitPlugin: FC<{ value: string }> = ({ value }) => {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const initialized = useRef(false);
|
||||
useEffect(() => {
|
||||
if (initialized.current || !value) return;
|
||||
initialized.current = true;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (parsed?.root) {
|
||||
const state = editor.parseEditorState(JSON.stringify(parsed));
|
||||
editor.setEditorState(state);
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
}, [editor, value]);
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
interface NoteEditorProps {
|
||||
nodeId: string;
|
||||
value: string;
|
||||
fontSize?: number;
|
||||
onChange: (val: string) => void;
|
||||
onFormatChange?: (state: FormatState) => void;
|
||||
}
|
||||
|
||||
const NoteEditor: FC<NoteEditorProps> = ({ nodeId, value, fontSize = 12, onChange, onFormatChange }) => {
|
||||
const { t } = useTranslation();
|
||||
const [linkState, setLinkState] = useState<{ url: string; rect: DOMRect } | null>(null);
|
||||
const [editLinkRect, setEditLinkRect] = useState<{ url: string; rect: DOMRect } | null>(null);
|
||||
const removingLink = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!linkState) return;
|
||||
const handler = () => setLinkState(null);
|
||||
window.addEventListener('mousedown', handler);
|
||||
return () => window.removeEventListener('mousedown', handler);
|
||||
}, [!!linkState]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const { id, url, rect: passedRect } = (e as CustomEvent).detail;
|
||||
if (id !== nodeId) return;
|
||||
if (passedRect) {
|
||||
setEditLinkRect({ url: url || '', rect: passedRect });
|
||||
return;
|
||||
}
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.rangeCount > 0) {
|
||||
const r = sel.getRangeAt(0).getBoundingClientRect();
|
||||
if (r.width > 0 || r.height > 0) { setEditLinkRect({ url: url || '', rect: r }); return; }
|
||||
}
|
||||
const linkEl = document.querySelector(`[data-note-id="${nodeId}"] a.note-link`) as HTMLElement;
|
||||
const rect = linkEl?.getBoundingClientRect() ?? new DOMRect(window.innerWidth / 2, 200, 0, 0);
|
||||
setEditLinkRect({ url: url || '', rect });
|
||||
};
|
||||
window.addEventListener('note:edit-link', handler);
|
||||
return () => window.removeEventListener('note:edit-link', handler);
|
||||
}, [nodeId]);
|
||||
|
||||
const handleFormatChange = useCallback((state: FormatState) => {
|
||||
onFormatChange?.(state);
|
||||
if (state.linkUrl) {
|
||||
requestAnimationFrame(() => {
|
||||
if (removingLink.current) { removingLink.current = false; return; }
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.rangeCount > 0) {
|
||||
const rect = sel.getRangeAt(0).getBoundingClientRect();
|
||||
if (rect.width > 0 || rect.height > 0) {
|
||||
setLinkState({ url: state.linkUrl!, rect });
|
||||
return;
|
||||
}
|
||||
}
|
||||
// fallback: find the link element in the correct editor
|
||||
const editorEl = document.querySelector(`[data-note-id="${nodeId}"] a.note-link`) as HTMLElement;
|
||||
if (editorEl) {
|
||||
setLinkState({ url: state.linkUrl!, rect: editorEl.getBoundingClientRect() });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setLinkState(null);
|
||||
}
|
||||
}, [onFormatChange]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{NOTE_STYLES}</style>
|
||||
<LexicalComposer initialConfig={{ namespace: `note-${nodeId}`, theme, nodes: NOTE_NODES, onError: console.error }}>
|
||||
<div style={{ position: 'relative' }} data-note-id={nodeId}>
|
||||
<RichTextPlugin
|
||||
contentEditable={
|
||||
<ContentEditable
|
||||
style={{ minHeight: 60, outline: 'none', resize: 'none', fontSize: '12px', lineHeight: '18px', color: '#374151', overflow: 'auto', cursor: 'auto' }}
|
||||
/>
|
||||
}
|
||||
placeholder={
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, color: '#9CA3AF', lineHeight: '18px', pointerEvents: 'none' }}>
|
||||
{t('workflow.config.notes.placeholder')}
|
||||
</div>
|
||||
}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
<HistoryPlugin />
|
||||
<ListPlugin />
|
||||
<LinkPlugin />
|
||||
<OnChangePlugin onChange={(editorState) => onChange(JSON.stringify(editorState.toJSON()))} />
|
||||
<NoteInitPlugin value={value} />
|
||||
<NoteFormatPlugin nodeId={nodeId} fontSize={fontSize} onFormatChange={handleFormatChange} />
|
||||
{editLinkRect && (
|
||||
<EditLinkPopover
|
||||
rect={editLinkRect.rect}
|
||||
initialUrl={editLinkRect.url}
|
||||
onConfirm={(url) => {
|
||||
removingLink.current = true;
|
||||
window.dispatchEvent(new CustomEvent('note:format', { detail: { id: nodeId, format: 'link', value: url || null } }));
|
||||
setEditLinkRect(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{linkState && (
|
||||
<LinkPopover
|
||||
url={linkState.url}
|
||||
rect={linkState.rect}
|
||||
onEdit={() => {
|
||||
removingLink.current = true;
|
||||
const { rect, url } = linkState;
|
||||
setLinkState(null);
|
||||
setEditLinkRect({ url, rect });
|
||||
}}
|
||||
onRemove={() => {
|
||||
removingLink.current = true;
|
||||
setLinkState(null);
|
||||
window.dispatchEvent(new CustomEvent('note:format', { detail: { id: nodeId, format: 'link', value: null } }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</LexicalComposer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoteEditor;
|
||||
@@ -0,0 +1,163 @@
|
||||
import { type FC } from 'react';
|
||||
import { Flex, Dropdown, type MenuProps, Switch, Button, Divider } from 'antd';
|
||||
import { UnorderedListOutlined, BoldOutlined, ItalicOutlined, StrikethroughOutlined, LinkOutlined, DashOutlined } from '@ant-design/icons';
|
||||
import { Node } from '@antv/x6';
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { THEME_MAP } from '../../../constant';
|
||||
const FONT_SIZES = [
|
||||
{ label: '小', value: 12 },
|
||||
{ label: '中', value: 14 },
|
||||
{ label: '大', value: 16 },
|
||||
];
|
||||
|
||||
interface NoteNodeToolbarProps {
|
||||
node: Node;
|
||||
onFormat: (type: string, value?: unknown) => void;
|
||||
toolConfig: Record<string, number | boolean>;
|
||||
nodeId: string;
|
||||
}
|
||||
|
||||
const NoteNodeToolbar: FC<NoteNodeToolbarProps> = ({ node, onFormat, toolConfig, nodeId }) => {
|
||||
const data = node?.getData() || {};
|
||||
const { t } = useTranslation();
|
||||
|
||||
const colorItems: MenuProps['items'] = Object.entries(THEME_MAP).map(([key, theme]) => ({
|
||||
key,
|
||||
label: (
|
||||
<div
|
||||
className="rb:w-5 rb:h-5 rb:rounded-full rb:cursor-pointer rb:border rb:border-gray-200"
|
||||
style={{ background: theme.bg }}
|
||||
onClick={() => onFormat('color', key)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
const fontSizeItems: MenuProps['items'] = FONT_SIZES.map(({ label, value }) => ({
|
||||
key: value,
|
||||
label: <span onClick={() => onFormat('fontSize', value)}>{label}</span>,
|
||||
}));
|
||||
|
||||
const currentFontSize = FONT_SIZES.find(f => f.value === toolConfig.fontSize)?.label ?? '小';
|
||||
|
||||
const handleClick: MenuProps['onClick'] = (e) => {
|
||||
switch (e.key) {
|
||||
case 'delete':
|
||||
node.remove()
|
||||
break;
|
||||
case 'copy':
|
||||
break;
|
||||
}
|
||||
}
|
||||
const handleChange = (type: string) => {
|
||||
let show_author = data.config.show_author.defaultValue
|
||||
if(type === 'showAuth'){
|
||||
show_author = !show_author
|
||||
}
|
||||
node.setData({
|
||||
...data,
|
||||
config: {
|
||||
...data.config,
|
||||
show_author: {
|
||||
...data.config.show_author,
|
||||
defaultValue: show_author
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
gap={8}
|
||||
className="rb:absolute rb:-top-11 rb:left-1/2 rb:-translate-x-1/2 rb:bg-white rb:z-10 rb:whitespace-nowrap rb:rounded-lg rb:py-1! rb:px-3!"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Color picker */}
|
||||
<Dropdown menu={{ items: colorItems }} trigger={['click']}>
|
||||
<div
|
||||
className="rb:w-5 rb:h-5 rb:rounded-full rb:cursor-pointer rb:border rb:border-gray-200"
|
||||
style={{ background: THEME_MAP[data.bgColor]?.bg || THEME_MAP.blue.bg }}
|
||||
/>
|
||||
</Dropdown>
|
||||
|
||||
<Divider type="vertical" />
|
||||
|
||||
{/* Font size */}
|
||||
<Dropdown menu={{ items: fontSizeItems }} trigger={['click']}>
|
||||
<Flex align="center" gap={4} className="rb:cursor-pointer rb:text-xs rb:text-gray-600 rb:select-none">
|
||||
<span className="rb:text-xs">Aa</span>
|
||||
<span className="rb:text-xs">{currentFontSize}</span>
|
||||
</Flex>
|
||||
</Dropdown>
|
||||
|
||||
<Divider type="vertical" />
|
||||
|
||||
{/* Bold */}
|
||||
<Button
|
||||
type={toolConfig.bold ? 'primary' : 'text'}
|
||||
icon={<BoldOutlined />}
|
||||
onClick={() => onFormat('bold')}
|
||||
/>
|
||||
|
||||
{/* Italic */}
|
||||
<Button
|
||||
type={toolConfig.italic ? 'primary' : 'text'}
|
||||
icon={<ItalicOutlined />}
|
||||
onClick={() => onFormat('italic')}
|
||||
/>
|
||||
|
||||
{/* Strikethrough */}
|
||||
<Button
|
||||
type={toolConfig.strikethrough ? 'primary' : 'text'}
|
||||
icon={<StrikethroughOutlined />}
|
||||
onClick={() => onFormat('strikethrough')}
|
||||
/>
|
||||
|
||||
{/* Link */}
|
||||
<Button
|
||||
type={toolConfig.link ? 'primary' : 'text'}
|
||||
icon={<LinkOutlined />}
|
||||
onClick={() => {
|
||||
const sel = window.getSelection();
|
||||
const rect = sel && sel.rangeCount > 0 ? sel.getRangeAt(0).getBoundingClientRect() : undefined;
|
||||
window.dispatchEvent(new CustomEvent('note:edit-link', { detail: { id: nodeId, url: '', rect } }));
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* List */}
|
||||
<Button
|
||||
type={toolConfig.list ? 'primary' : 'text'}
|
||||
icon={<UnorderedListOutlined />}
|
||||
onClick={() => onFormat('list')}
|
||||
/>
|
||||
|
||||
<Divider type="vertical" />
|
||||
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
// { key: 'copy', label: t('common.copy') },
|
||||
{
|
||||
key: 'showAuth',
|
||||
label: <Flex align="center" gap={24}>
|
||||
{t('workflow.config.notes.showAuth')}
|
||||
<Switch
|
||||
size="small"
|
||||
checked={data.config.show_author.defaultValue}
|
||||
onChange={() => handleChange('showAuth')}
|
||||
/>
|
||||
</Flex>
|
||||
},
|
||||
{ key: 'delete', label: <Flex>{t('common.delete')}</Flex> },
|
||||
],
|
||||
onClick: handleClick
|
||||
}}
|
||||
>
|
||||
<DashOutlined />
|
||||
</Dropdown>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoteNodeToolbar;
|
||||
155
web/src/views/Workflow/components/Nodes/NoteNode/index.tsx
Normal file
155
web/src/views/Workflow/components/Nodes/NoteNode/index.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import type { ReactShapeConfig } from '@antv/x6-react-shape';
|
||||
import { Flex } from 'antd';
|
||||
|
||||
import NoteEditor from './NoteEditor';
|
||||
import NoteNodeToolbar from './NoteNodeToolbar';
|
||||
import { THEME_MAP } from '../../../constant'
|
||||
|
||||
const MIN_W = 240;
|
||||
const MIN_H = 120;
|
||||
|
||||
const NoteNode: ReactShapeConfig['component'] = ({ node }) => {
|
||||
const data = node?.getData() || {};
|
||||
const nodeId = node?.id || '';
|
||||
const startRef = useRef<{ x: number; y: number; w: number; h: number } | null>(null);
|
||||
const [toolConfig, setToolConfig] = useState({
|
||||
fontSize: 12,
|
||||
bold: false,
|
||||
italic: false,
|
||||
strikethrough: false,
|
||||
list: false,
|
||||
})
|
||||
|
||||
const handleFormat = (type: string, value?: unknown) => {
|
||||
console.log('handleFormat', type, value)
|
||||
if (type === 'color') {
|
||||
node?.setData({
|
||||
...data,
|
||||
config: {
|
||||
...data.config,
|
||||
theme: {
|
||||
...data.config.theme,
|
||||
defaultValue: value
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (type === 'fontSize') {
|
||||
window.dispatchEvent(new CustomEvent('note:format', { detail: { id: nodeId, format: 'fontSize', value } }));
|
||||
} else if (type === 'link') {
|
||||
window.dispatchEvent(new CustomEvent('note:format', { detail: { id: nodeId, format: 'link', value: value || null } }));
|
||||
} else if (type === 'list') {
|
||||
window.dispatchEvent(new CustomEvent('note:format', { detail: { id: nodeId, format: 'list', value: !toolConfig.list } }));
|
||||
} else {
|
||||
window.dispatchEvent(new CustomEvent('note:format', { detail: { id: nodeId, format: type } }));
|
||||
}
|
||||
|
||||
setToolConfig(prev => ({ ...prev, [type]: value || !prev[type as unknown as keyof typeof toolConfig] }))
|
||||
};
|
||||
|
||||
const onResizeMouseDown = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const size = node?.getSize();
|
||||
if (!size) return;
|
||||
startRef.current = { x: e.clientX, y: e.clientY, w: size.width, h: size.height };
|
||||
|
||||
const onMouseMove = (ev: MouseEvent) => {
|
||||
if (!startRef.current) return;
|
||||
const w = Math.max(MIN_W, startRef.current.w + ev.clientX - startRef.current.x);
|
||||
const h = Math.max(MIN_H, startRef.current.h + ev.clientY - startRef.current.y);
|
||||
|
||||
node?.setData({
|
||||
...data,
|
||||
config: {
|
||||
...data.config,
|
||||
width: {
|
||||
...data.config.width,
|
||||
defaultValue: w
|
||||
},
|
||||
height: {
|
||||
...data.config.height,
|
||||
defaultValue: h
|
||||
}
|
||||
}
|
||||
});
|
||||
node?.prop('size', { width: w, height: h });
|
||||
};
|
||||
const onMouseUp = () => {
|
||||
startRef.current = null;
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
const updateText = (value: string) => {
|
||||
node.setData({
|
||||
...data,
|
||||
config: {
|
||||
...data.config,
|
||||
text: {
|
||||
...data.config.text,
|
||||
defaultValue: value
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const theme = THEME_MAP[data.config?.theme?.defaultValue || 'blue'] || THEME_MAP['blue']
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rb:relative rb:h-full rb:w-full rb:rounded-2xl rb:border"
|
||||
style={{
|
||||
background: theme.bg,
|
||||
borderColor: data.isSelected ? theme.outer : theme.border,
|
||||
}}
|
||||
>
|
||||
<div className="rb:h-4 rb:rounded-tl-2xl rb:rounded-tr-2xl"
|
||||
style={{
|
||||
background: theme.title
|
||||
}}
|
||||
></div>
|
||||
{data.isSelected && <NoteNodeToolbar node={node!} nodeId={nodeId} toolConfig={toolConfig} onFormat={handleFormat} />}
|
||||
|
||||
<div
|
||||
className="rb:w-full rb:h-[calc(100%-36px)] rb:p-2.5 rb:overflow-auto"
|
||||
onMouseDown={e => {
|
||||
e.stopPropagation()
|
||||
node?.setData({ ...node.getData(), isSelected: true })
|
||||
}}
|
||||
onWheel={e => e.stopPropagation()}
|
||||
>
|
||||
<NoteEditor
|
||||
nodeId={nodeId}
|
||||
value={data.config.text.defaultValue || ''}
|
||||
fontSize={toolConfig.fontSize}
|
||||
onChange={updateText}
|
||||
onFormatChange={(state) => setToolConfig(prev => ({ ...prev, ...state }))}
|
||||
/>
|
||||
</div>
|
||||
<Flex align="center" justify="space-between" className="rb:pl-2.5! rb:pr-1!">
|
||||
<div className="rb:text-[12px] rb:text-[#5B6167]">
|
||||
{data.config.show_author.defaultValue
|
||||
? data.config.author.defaultValue
|
||||
: undefined
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
{/* <div className="rb:size-4 rb:border-b-[4px] rb:border-r-[4px] rb:border-[#EBEBEB] rb:rounded-2xl"></div> */}
|
||||
<div
|
||||
onMouseDown={onResizeMouseDown}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M12 9.75V6H13.5V9.75C13.5 11.8211 11.8211 13.5 9.75 13.5H6V12H9.75C10.9926 12 12 10.9926 12 9.75Z" fill="black" fillOpacity="0.16"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoteNode;
|
||||
@@ -2,13 +2,14 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 15:06:18
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-11 12:07:20
|
||||
* @Last Modified time: 2026-03-09 13:41:19
|
||||
*/
|
||||
import LoopNode from './components/Nodes/LoopNode';
|
||||
import NormalNode from './components/Nodes/NormalNode';
|
||||
import ConditionNode from './components/Nodes/ConditionNode';
|
||||
import GroupStartNode from './components/Nodes/GroupStartNode';
|
||||
import AddNode from './components/Nodes/AddNode'
|
||||
import NoteNode from './components/Nodes/NoteNode';
|
||||
import type { PortMetadata, GroupMetadata } from '@antv/x6/lib/model/port';
|
||||
import type { ReactShapeConfig } from '@antv/x6-react-shape';
|
||||
|
||||
@@ -525,6 +526,73 @@ export const nodeLibrary: NodeLibrary[] = [
|
||||
// ]
|
||||
// },
|
||||
];
|
||||
|
||||
export const THEME_MAP: Record<string, { outer: string; title: string; bg: string; border: string }> = {
|
||||
blue: {
|
||||
outer: '#2E90FA',
|
||||
title: '#D1E9FF',
|
||||
bg: '#EFF8FF',
|
||||
border: '#84CAFF',
|
||||
},
|
||||
cyan: {
|
||||
outer: '#06AED4',
|
||||
title: '#CFF9FE',
|
||||
bg: '#ECFDFF',
|
||||
border: '#67E3F9',
|
||||
},
|
||||
green: {
|
||||
outer: '#16B364',
|
||||
title: '#D3F8DF',
|
||||
bg: '#EDFCF2',
|
||||
border: '#73E2A3',
|
||||
},
|
||||
yellow: {
|
||||
outer: '#EAAA08',
|
||||
title: '#FEF7C3',
|
||||
bg: '#FEFBE8',
|
||||
border: '#FDE272',
|
||||
},
|
||||
pink: {
|
||||
outer: '#EE46BC',
|
||||
title: '#FCE7F6',
|
||||
bg: '#FDF2FA',
|
||||
border: '#FAA7E0',
|
||||
},
|
||||
violet: {
|
||||
outer: '#875BF7',
|
||||
title: '#ECE9FE',
|
||||
bg: '#F5F3FF',
|
||||
border: '#C3B5FD',
|
||||
},
|
||||
}
|
||||
|
||||
export const notesConfig = {
|
||||
type: "notes", icon: templateRenderingIcon,
|
||||
config: {
|
||||
text: {
|
||||
type: 'define',
|
||||
},
|
||||
theme: {
|
||||
type: 'define',
|
||||
defaultValue: 'blue',
|
||||
},
|
||||
width: {
|
||||
type: 'define',
|
||||
width: 240,
|
||||
},
|
||||
height: {
|
||||
type: 'define',
|
||||
height: 120,
|
||||
},
|
||||
author: {
|
||||
type: 'define',
|
||||
},
|
||||
show_author: {
|
||||
type: 'define',
|
||||
defaultValue: true
|
||||
}
|
||||
}
|
||||
}
|
||||
export const unknownNode = {
|
||||
type: 'unknown',
|
||||
icon: unknownIcon
|
||||
@@ -576,6 +644,12 @@ export const nodeRegisterLibrary: ReactShapeConfig[] = [
|
||||
height: 44,
|
||||
component: AddNode,
|
||||
},
|
||||
{
|
||||
shape: 'notes-node',
|
||||
width: nodeWidth,
|
||||
height: 120,
|
||||
component: NoteNode,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -801,6 +875,11 @@ export const graphNodeLibrary: Record<string, NodeConfig> = {
|
||||
groups: {left: { position: 'left', markup: portMarkup, attrs: portAttrs }},
|
||||
items: [{ group: 'left' }],
|
||||
},
|
||||
},
|
||||
notes: {
|
||||
width: nodeWidth,
|
||||
height: 120,
|
||||
shape: 'notes-node',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,9 +12,10 @@ import { Graph, Node, MiniMap, Snapline, Clipboard, Keyboard, type Edge } from '
|
||||
import { register } from '@antv/x6-react-shape';
|
||||
import type { PortMetadata } from '@antv/x6/lib/model/port';
|
||||
|
||||
import { nodeRegisterLibrary, graphNodeLibrary, nodeLibrary, portMarkup, portAttrs, edgeAttrs, edge_color, edge_selected_color, portTextAttrs, defaultAbsolutePortGroups, nodeWidth, unknownNode, noteNode } from '../constant';
|
||||
import { nodeRegisterLibrary, graphNodeLibrary, nodeLibrary, portMarkup, portAttrs, edgeAttrs, edge_color, edge_selected_color, portTextAttrs, defaultAbsolutePortGroups, nodeWidth, unknownNode, noteNode, notesConfig } from '../constant';
|
||||
import type { WorkflowConfig, NodeProperties, ChatVariable } from '../types';
|
||||
import { getWorkflowConfig, saveWorkflowConfig } from '@/api/application'
|
||||
import { useUser } from '@/store/user';
|
||||
|
||||
/**
|
||||
* Props for useWorkflowGraph hook
|
||||
@@ -64,6 +65,8 @@ export interface UseWorkflowGraphReturn {
|
||||
chatVariables: ChatVariable[];
|
||||
/** Function to update chat variables */
|
||||
setChatVariables: React.Dispatch<React.SetStateAction<ChatVariable[]>>;
|
||||
|
||||
handleAddNotes: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,6 +83,7 @@ export const useWorkflowGraph = ({
|
||||
const { id } = useParams();
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation()
|
||||
const { user } = useUser();
|
||||
|
||||
// Refs
|
||||
const graphRef = useRef<Graph>();
|
||||
@@ -128,7 +132,7 @@ export const useWorkflowGraph = ({
|
||||
if (nodes.length) {
|
||||
const nodeList = nodes.map(node => {
|
||||
const { id, type, name, position, config = {} } = node
|
||||
let nodeLibraryConfig = [...nodeLibrary, { nodes: [unknownNode, noteNode] }]
|
||||
let nodeLibraryConfig = [...nodeLibrary, { nodes: [unknownNode, notesConfig] }]
|
||||
.flatMap(category => category.nodes)
|
||||
.find(n => n.type === type)
|
||||
nodeLibraryConfig = JSON.parse(JSON.stringify({ config: {}, ...nodeLibraryConfig })) as NodeProperties
|
||||
@@ -197,6 +201,13 @@ export const useWorkflowGraph = ({
|
||||
data: { ...node, ...nodeLibraryConfig},
|
||||
...position,
|
||||
}
|
||||
|
||||
if (type === 'notes') {
|
||||
const w = config.width;
|
||||
const h = config.height;
|
||||
if (w) nodeConfig.width = w as number;
|
||||
if (h) nodeConfig.height = h as number;
|
||||
}
|
||||
|
||||
// Generate ports dynamically for if-else node based on cases
|
||||
if (type === 'if-else' && config.cases && Array.isArray(config.cases)) {
|
||||
@@ -461,11 +472,12 @@ export const useWorkflowGraph = ({
|
||||
*/
|
||||
const nodeClick = ({ node }: { node: Node }) => {
|
||||
// Ignore add-node type node clicks
|
||||
if (node.getData()?.type === 'add-node' || node.getData().type === 'break' || node.getData().type === 'cycle-start') {
|
||||
const nodeData = node.getData()
|
||||
if (nodeData?.type === 'add-node' || nodeData.type === 'break' || nodeData.type === 'cycle-start') {
|
||||
setSelectedNode(null)
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const nodes = graphRef.current?.getNodes();
|
||||
|
||||
nodes?.forEach(vo => {
|
||||
@@ -478,10 +490,12 @@ export const useWorkflowGraph = ({
|
||||
}
|
||||
});
|
||||
node.setData({
|
||||
...node.getData(),
|
||||
...nodeData,
|
||||
isSelected: true,
|
||||
});
|
||||
setSelectedNode(node);
|
||||
if (nodeData.type !== 'notes') {
|
||||
setSelectedNode(node);
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Handle edge click event
|
||||
@@ -859,8 +873,31 @@ export const useWorkflowGraph = ({
|
||||
init();
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
const handleNoteKeydown = (e: KeyboardEvent) => {
|
||||
if (!graphRef.current) return;
|
||||
const selectedNote = graphRef.current.getNodes().find(n => n.getData()?.isSelected && n.getData()?.type === 'notes');
|
||||
if (!selectedNote) return;
|
||||
const isMeta = e.ctrlKey || e.metaKey;
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
// Only delete node when editor is not focused on text
|
||||
const active = document.activeElement;
|
||||
if (active && (active as HTMLElement).isContentEditable) return;
|
||||
deleteEvent();
|
||||
} else if (isMeta && e.key === 'c') {
|
||||
copyEvent();
|
||||
} else if (isMeta && e.key === 'v') {
|
||||
parseEvent();
|
||||
} else if (isMeta && e.key === 'd') {
|
||||
e.preventDefault();
|
||||
deleteEvent();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleNoteKeydown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
window.removeEventListener('keydown', handleNoteKeydown);
|
||||
graphRef.current?.dispose();
|
||||
};
|
||||
}, []);
|
||||
@@ -884,7 +921,7 @@ export const useWorkflowGraph = ({
|
||||
.flatMap(category => category.nodes)
|
||||
.find(n => n.type === dragData.type);
|
||||
nodeLibraryConfig = JSON.parse(JSON.stringify({ config: {}, ...nodeLibraryConfig })) as NodeProperties
|
||||
|
||||
|
||||
// Create clean node data, only keep necessary fields
|
||||
const cleanNodeData = {
|
||||
id: `${dragData.type.replace(/-/g, '_')}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
@@ -1103,6 +1140,32 @@ export const useWorkflowGraph = ({
|
||||
})
|
||||
}
|
||||
|
||||
const handleAddNotes = () => {
|
||||
if (!graphRef.current) return;
|
||||
const nodeConfig: NodeProperties = JSON.parse(JSON.stringify(notesConfig));
|
||||
nodeConfig.config = {
|
||||
...nodeConfig.config,
|
||||
author: { type: 'define', defaultValue: user?.username || '' },
|
||||
};
|
||||
const cleanNodeData = {
|
||||
id: `notes_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
name: t('workflow.notes'),
|
||||
...nodeConfig,
|
||||
};
|
||||
const container = graphRef.current.container;
|
||||
const nodeW = graphNodeLibrary.notes?.width || nodeWidth;
|
||||
const nodeH = graphNodeLibrary.notes?.height || 100;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const center = graphRef.current.clientToLocal(rect.left + rect.width / 2, rect.top + rect.height / 2);
|
||||
graphRef.current.addNode({
|
||||
...(graphNodeLibrary.notes || graphNodeLibrary.default),
|
||||
x: center.x - nodeW / 2,
|
||||
y: center.y - nodeH / 2,
|
||||
id: cleanNodeData.id,
|
||||
data: { ...cleanNodeData },
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
setConfig,
|
||||
@@ -1120,6 +1183,7 @@ export const useWorkflowGraph = ({
|
||||
parseEvent,
|
||||
handleSave,
|
||||
chatVariables,
|
||||
setChatVariables
|
||||
setChatVariables,
|
||||
handleAddNotes
|
||||
};
|
||||
};
|
||||
|
||||
@@ -38,7 +38,8 @@ const Workflow = forwardRef<WorkflowRef>((_props, ref) => {
|
||||
parseEvent,
|
||||
handleSave,
|
||||
chatVariables,
|
||||
setChatVariables
|
||||
setChatVariables,
|
||||
handleAddNotes
|
||||
} = useWorkflowGraph({ containerRef, miniMapRef });
|
||||
|
||||
const onDragOver = (event: React.DragEvent) => {
|
||||
@@ -95,6 +96,7 @@ const Workflow = forwardRef<WorkflowRef>((_props, ref) => {
|
||||
canRedo={canRedo}
|
||||
onUndo={onUndo}
|
||||
onRedo={onRedo}
|
||||
addNotes={handleAddNotes}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user