Merge pull request #521 from SuanmoSuanyangTechnology/feature/notes_zy

feat(web): 注释节点
This commit is contained in:
yingzhao
2026-03-09 17:36:52 +08:00
committed by GitHub
11 changed files with 860 additions and 13 deletions

View File

@@ -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',

View File

@@ -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: '值',

View File

@@ -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>
</>
);

View File

@@ -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;

View File

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

View File

@@ -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;

View File

@@ -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;

View 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;

View File

@@ -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',
}
}

View File

@@ -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
};
};

View File

@@ -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>