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',
|
self_optimization: 'Self Optimization',
|
||||||
process_evolution: 'Process Evolution',
|
process_evolution: 'Process Evolution',
|
||||||
unknown: 'Unknown Node',
|
unknown: 'Unknown Node',
|
||||||
|
notes: 'Sticky Note',
|
||||||
|
|
||||||
clickToConfigure: 'Click to configure node parameters',
|
clickToConfigure: 'Click to configure node parameters',
|
||||||
nodeProperties: 'Node Properties',
|
nodeProperties: 'Node Properties',
|
||||||
@@ -2227,6 +2228,12 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
|||||||
output_variables: 'Output Variables',
|
output_variables: 'Output Variables',
|
||||||
refreshTip: 'Sync function signature to code',
|
refreshTip: 'Sync function signature to code',
|
||||||
},
|
},
|
||||||
|
notes: {
|
||||||
|
showAuth: 'Show Author',
|
||||||
|
enterLink: 'Enter Link URL',
|
||||||
|
placeholder: 'Enter note...',
|
||||||
|
removeLink: 'Remove Link',
|
||||||
|
},
|
||||||
name: 'Key',
|
name: 'Key',
|
||||||
type: 'Type',
|
type: 'Type',
|
||||||
value: 'Value',
|
value: 'Value',
|
||||||
|
|||||||
@@ -2036,6 +2036,7 @@ export const zh = {
|
|||||||
self_optimization: '自我优化',
|
self_optimization: '自我优化',
|
||||||
process_evolution: '流程演化',
|
process_evolution: '流程演化',
|
||||||
unknown: '未知节点',
|
unknown: '未知节点',
|
||||||
|
notes: '便签',
|
||||||
|
|
||||||
clickToConfigure: '点击配置节点参数',
|
clickToConfigure: '点击配置节点参数',
|
||||||
nodeProperties: '节点属性',
|
nodeProperties: '节点属性',
|
||||||
@@ -2226,6 +2227,12 @@ export const zh = {
|
|||||||
unknown: {
|
unknown: {
|
||||||
replaceNodeType: '替换节点'
|
replaceNodeType: '替换节点'
|
||||||
},
|
},
|
||||||
|
notes: {
|
||||||
|
showAuth: '显示作者',
|
||||||
|
enterLink: '输入链接 URL',
|
||||||
|
placeholder: '输入注释...',
|
||||||
|
removeLink: '取消链接',
|
||||||
|
},
|
||||||
name: '键',
|
name: '键',
|
||||||
type: '类型',
|
type: '类型',
|
||||||
value: '值',
|
value: '值',
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { Select } from 'antd';
|
import { Select, Divider } from 'antd';
|
||||||
// import { Node } from '@antv/x6';
|
// import { Node } from '@antv/x6';
|
||||||
import type { GraphRef } from '../types'
|
import type { GraphRef } from '../types'
|
||||||
import { PlusOutlined, MinusOutlined } from '@ant-design/icons'
|
import { PlusOutlined, MinusOutlined, FileAddOutlined } from '@ant-design/icons'
|
||||||
|
|
||||||
interface CanvasToolbarProps {
|
interface CanvasToolbarProps {
|
||||||
miniMapRef: React.RefObject<HTMLDivElement>;
|
miniMapRef: React.RefObject<HTMLDivElement>;
|
||||||
@@ -14,6 +14,7 @@ interface CanvasToolbarProps {
|
|||||||
canRedo: boolean;
|
canRedo: boolean;
|
||||||
onUndo: () => void;
|
onUndo: () => void;
|
||||||
onRedo: () => void;
|
onRedo: () => void;
|
||||||
|
addNotes: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CanvasToolbar: FC<CanvasToolbarProps> = ({
|
const CanvasToolbar: FC<CanvasToolbarProps> = ({
|
||||||
@@ -26,6 +27,7 @@ const CanvasToolbar: FC<CanvasToolbarProps> = ({
|
|||||||
// canRedo,
|
// canRedo,
|
||||||
// onUndo,
|
// onUndo,
|
||||||
// onRedo,
|
// 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 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)} />
|
<MinusOutlined className="rb:text-[16px] rb:cursor-pointer" onClick={() => graphRef.current?.zoom(-0.1)} />
|
||||||
<Select
|
<Select
|
||||||
value={Math.round(zoomLevel * 100)}
|
value={Math.round(zoomLevel * 100)}
|
||||||
@@ -182,6 +184,8 @@ const CanvasToolbar: FC<CanvasToolbarProps> = ({
|
|||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
<PlusOutlined className="rb:text-[16px] rb:cursor-pointer" onClick={() => graphRef.current?.zoom(0.1)} />
|
<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>
|
</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
|
* @Author: ZhaoYing
|
||||||
* @Date: 2026-02-03 15:06:18
|
* @Date: 2026-02-03 15:06:18
|
||||||
* @Last Modified by: ZhaoYing
|
* @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 LoopNode from './components/Nodes/LoopNode';
|
||||||
import NormalNode from './components/Nodes/NormalNode';
|
import NormalNode from './components/Nodes/NormalNode';
|
||||||
import ConditionNode from './components/Nodes/ConditionNode';
|
import ConditionNode from './components/Nodes/ConditionNode';
|
||||||
import GroupStartNode from './components/Nodes/GroupStartNode';
|
import GroupStartNode from './components/Nodes/GroupStartNode';
|
||||||
import AddNode from './components/Nodes/AddNode'
|
import AddNode from './components/Nodes/AddNode'
|
||||||
|
import NoteNode from './components/Nodes/NoteNode';
|
||||||
import type { PortMetadata, GroupMetadata } from '@antv/x6/lib/model/port';
|
import type { PortMetadata, GroupMetadata } from '@antv/x6/lib/model/port';
|
||||||
import type { ReactShapeConfig } from '@antv/x6-react-shape';
|
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 = {
|
export const unknownNode = {
|
||||||
type: 'unknown',
|
type: 'unknown',
|
||||||
icon: unknownIcon
|
icon: unknownIcon
|
||||||
@@ -576,6 +644,12 @@ export const nodeRegisterLibrary: ReactShapeConfig[] = [
|
|||||||
height: 44,
|
height: 44,
|
||||||
component: AddNode,
|
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 }},
|
groups: {left: { position: 'left', markup: portMarkup, attrs: portAttrs }},
|
||||||
items: [{ group: 'left' }],
|
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 { register } from '@antv/x6-react-shape';
|
||||||
import type { PortMetadata } from '@antv/x6/lib/model/port';
|
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 type { WorkflowConfig, NodeProperties, ChatVariable } from '../types';
|
||||||
import { getWorkflowConfig, saveWorkflowConfig } from '@/api/application'
|
import { getWorkflowConfig, saveWorkflowConfig } from '@/api/application'
|
||||||
|
import { useUser } from '@/store/user';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props for useWorkflowGraph hook
|
* Props for useWorkflowGraph hook
|
||||||
@@ -64,6 +65,8 @@ export interface UseWorkflowGraphReturn {
|
|||||||
chatVariables: ChatVariable[];
|
chatVariables: ChatVariable[];
|
||||||
/** Function to update chat variables */
|
/** Function to update chat variables */
|
||||||
setChatVariables: React.Dispatch<React.SetStateAction<ChatVariable[]>>;
|
setChatVariables: React.Dispatch<React.SetStateAction<ChatVariable[]>>;
|
||||||
|
|
||||||
|
handleAddNotes: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -80,6 +83,7 @@ export const useWorkflowGraph = ({
|
|||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const { user } = useUser();
|
||||||
|
|
||||||
// Refs
|
// Refs
|
||||||
const graphRef = useRef<Graph>();
|
const graphRef = useRef<Graph>();
|
||||||
@@ -128,7 +132,7 @@ export const useWorkflowGraph = ({
|
|||||||
if (nodes.length) {
|
if (nodes.length) {
|
||||||
const nodeList = nodes.map(node => {
|
const nodeList = nodes.map(node => {
|
||||||
const { id, type, name, position, config = {} } = node
|
const { id, type, name, position, config = {} } = node
|
||||||
let nodeLibraryConfig = [...nodeLibrary, { nodes: [unknownNode, noteNode] }]
|
let nodeLibraryConfig = [...nodeLibrary, { nodes: [unknownNode, notesConfig] }]
|
||||||
.flatMap(category => category.nodes)
|
.flatMap(category => category.nodes)
|
||||||
.find(n => n.type === type)
|
.find(n => n.type === type)
|
||||||
nodeLibraryConfig = JSON.parse(JSON.stringify({ config: {}, ...nodeLibraryConfig })) as NodeProperties
|
nodeLibraryConfig = JSON.parse(JSON.stringify({ config: {}, ...nodeLibraryConfig })) as NodeProperties
|
||||||
@@ -197,6 +201,13 @@ export const useWorkflowGraph = ({
|
|||||||
data: { ...node, ...nodeLibraryConfig},
|
data: { ...node, ...nodeLibraryConfig},
|
||||||
...position,
|
...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
|
// Generate ports dynamically for if-else node based on cases
|
||||||
if (type === 'if-else' && config.cases && Array.isArray(config.cases)) {
|
if (type === 'if-else' && config.cases && Array.isArray(config.cases)) {
|
||||||
@@ -461,11 +472,12 @@ export const useWorkflowGraph = ({
|
|||||||
*/
|
*/
|
||||||
const nodeClick = ({ node }: { node: Node }) => {
|
const nodeClick = ({ node }: { node: Node }) => {
|
||||||
// Ignore add-node type node clicks
|
// 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)
|
setSelectedNode(null)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodes = graphRef.current?.getNodes();
|
const nodes = graphRef.current?.getNodes();
|
||||||
|
|
||||||
nodes?.forEach(vo => {
|
nodes?.forEach(vo => {
|
||||||
@@ -478,10 +490,12 @@ export const useWorkflowGraph = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
node.setData({
|
node.setData({
|
||||||
...node.getData(),
|
...nodeData,
|
||||||
isSelected: true,
|
isSelected: true,
|
||||||
});
|
});
|
||||||
setSelectedNode(node);
|
if (nodeData.type !== 'notes') {
|
||||||
|
setSelectedNode(node);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Handle edge click event
|
* Handle edge click event
|
||||||
@@ -859,8 +873,31 @@ export const useWorkflowGraph = ({
|
|||||||
init();
|
init();
|
||||||
|
|
||||||
window.addEventListener('resize', handleResize);
|
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 () => {
|
return () => {
|
||||||
window.removeEventListener('resize', handleResize);
|
window.removeEventListener('resize', handleResize);
|
||||||
|
window.removeEventListener('keydown', handleNoteKeydown);
|
||||||
graphRef.current?.dispose();
|
graphRef.current?.dispose();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
@@ -884,7 +921,7 @@ export const useWorkflowGraph = ({
|
|||||||
.flatMap(category => category.nodes)
|
.flatMap(category => category.nodes)
|
||||||
.find(n => n.type === dragData.type);
|
.find(n => n.type === dragData.type);
|
||||||
nodeLibraryConfig = JSON.parse(JSON.stringify({ config: {}, ...nodeLibraryConfig })) as NodeProperties
|
nodeLibraryConfig = JSON.parse(JSON.stringify({ config: {}, ...nodeLibraryConfig })) as NodeProperties
|
||||||
|
|
||||||
// Create clean node data, only keep necessary fields
|
// Create clean node data, only keep necessary fields
|
||||||
const cleanNodeData = {
|
const cleanNodeData = {
|
||||||
id: `${dragData.type.replace(/-/g, '_')}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
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 {
|
return {
|
||||||
config,
|
config,
|
||||||
setConfig,
|
setConfig,
|
||||||
@@ -1120,6 +1183,7 @@ export const useWorkflowGraph = ({
|
|||||||
parseEvent,
|
parseEvent,
|
||||||
handleSave,
|
handleSave,
|
||||||
chatVariables,
|
chatVariables,
|
||||||
setChatVariables
|
setChatVariables,
|
||||||
|
handleAddNotes
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ const Workflow = forwardRef<WorkflowRef>((_props, ref) => {
|
|||||||
parseEvent,
|
parseEvent,
|
||||||
handleSave,
|
handleSave,
|
||||||
chatVariables,
|
chatVariables,
|
||||||
setChatVariables
|
setChatVariables,
|
||||||
|
handleAddNotes
|
||||||
} = useWorkflowGraph({ containerRef, miniMapRef });
|
} = useWorkflowGraph({ containerRef, miniMapRef });
|
||||||
|
|
||||||
const onDragOver = (event: React.DragEvent) => {
|
const onDragOver = (event: React.DragEvent) => {
|
||||||
@@ -95,6 +96,7 @@ const Workflow = forwardRef<WorkflowRef>((_props, ref) => {
|
|||||||
canRedo={canRedo}
|
canRedo={canRedo}
|
||||||
onUndo={onUndo}
|
onUndo={onUndo}
|
||||||
onRedo={onRedo}
|
onRedo={onRedo}
|
||||||
|
addNotes={handleAddNotes}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user