From 33d12c43b27c4308ae04734b0f9f967298fb7387 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 9 Mar 2026 17:30:43 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(web):=20=E6=B3=A8=E9=87=8A=E8=8A=82?= =?UTF-8?q?=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/i18n/en.ts | 7 + web/src/i18n/zh.ts | 7 + .../Workflow/components/CanvasToolbar.tsx | 10 +- .../NoteNode/NoteEditor/NoteFormatPlugin.tsx | 108 ++++++++++ .../NoteNode/NoteEditor/NoteLinkPopovers.tsx | 74 +++++++ .../Nodes/NoteNode/NoteEditor/index.tsx | 184 ++++++++++++++++++ .../Nodes/NoteNode/NoteNodeToolbar.tsx | 163 ++++++++++++++++ .../components/Nodes/NoteNode/index.tsx | 155 +++++++++++++++ web/src/views/Workflow/constant.ts | 81 +++++++- .../views/Workflow/hooks/useWorkflowGraph.ts | 77 +++++++- web/src/views/Workflow/index.tsx | 4 +- 11 files changed, 857 insertions(+), 13 deletions(-) create mode 100644 web/src/views/Workflow/components/Nodes/NoteNode/NoteEditor/NoteFormatPlugin.tsx create mode 100644 web/src/views/Workflow/components/Nodes/NoteNode/NoteEditor/NoteLinkPopovers.tsx create mode 100644 web/src/views/Workflow/components/Nodes/NoteNode/NoteEditor/index.tsx create mode 100644 web/src/views/Workflow/components/Nodes/NoteNode/NoteNodeToolbar.tsx create mode 100644 web/src/views/Workflow/components/Nodes/NoteNode/index.tsx diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index ad9680d3..6e9239d6 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -2008,6 +2008,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', @@ -2195,6 +2196,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', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index c4d2df71..45825a09 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -2004,6 +2004,7 @@ export const zh = { self_optimization: '自我优化', process_evolution: '流程演化', unknown: '未知节点', + notes: '便签', clickToConfigure: '点击配置节点参数', nodeProperties: '节点属性', @@ -2194,6 +2195,12 @@ export const zh = { unknown: { replaceNodeType: '替换节点' }, + notes: { + showAuth: '显示作者', + enterLink: '输入链接 URL', + placeholder: '输入注释...', + removeLink: '取消链接', + }, name: '键', type: '类型', value: '值', diff --git a/web/src/views/Workflow/components/CanvasToolbar.tsx b/web/src/views/Workflow/components/CanvasToolbar.tsx index 8ca272e1..8bf2c641 100644 --- a/web/src/views/Workflow/components/CanvasToolbar.tsx +++ b/web/src/views/Workflow/components/CanvasToolbar.tsx @@ -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; @@ -14,6 +14,7 @@ interface CanvasToolbarProps { canRedo: boolean; onUndo: () => void; onRedo: () => void; + addNotes: () => void; } const CanvasToolbar: FC = ({ @@ -26,6 +27,7 @@ const CanvasToolbar: FC = ({ // canRedo, // onUndo, // onRedo, + addNotes, }) => { // 整理布局函数 /* @@ -152,7 +154,7 @@ const CanvasToolbar: FC = ({ {/* 小地图 */}
{/* 缩放控制按钮 */} -
+
graphRef.current?.zoom(-0.1)} /> setUrl(e.target.value)} + onKeyDown={e => e.stopPropagation()} + onPressEnter={confirm} + autoFocus + /> + + +
, + document.body + ); +}; diff --git a/web/src/views/Workflow/components/Nodes/NoteNode/NoteEditor/index.tsx b/web/src/views/Workflow/components/Nodes/NoteNode/NoteEditor/index.tsx new file mode 100644 index 00000000..06984d35 --- /dev/null +++ b/web/src/views/Workflow/components/Nodes/NoteNode/NoteEditor/index.tsx @@ -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 = ({ 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 ( + <> + + +
+ + } + placeholder={ +
+ {t('workflow.config.notes.placeholder')} +
+ } + ErrorBoundary={LexicalErrorBoundary} + /> + + + + onChange(JSON.stringify(editorState.toJSON()))} /> + + + {editLinkRect && ( + { + removingLink.current = true; + window.dispatchEvent(new CustomEvent('note:format', { detail: { id: nodeId, format: 'link', value: url || null } })); + setEditLinkRect(null); + }} + /> + )} + {linkState && ( + { + 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 } })); + }} + /> + )} +
+
+ + ); +}; + +export default NoteEditor; diff --git a/web/src/views/Workflow/components/Nodes/NoteNode/NoteNodeToolbar.tsx b/web/src/views/Workflow/components/Nodes/NoteNode/NoteNodeToolbar.tsx new file mode 100644 index 00000000..52c0eb22 --- /dev/null +++ b/web/src/views/Workflow/components/Nodes/NoteNode/NoteNodeToolbar.tsx @@ -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; + nodeId: string; +} + +const NoteNodeToolbar: FC = ({ node, onFormat, toolConfig, nodeId }) => { + const data = node?.getData() || {}; + const { t } = useTranslation(); + + const colorItems: MenuProps['items'] = Object.entries(THEME_MAP).map(([key, theme]) => ({ + key, + label: ( +
onFormat('color', key)} + /> + ), + })); + + const fontSizeItems: MenuProps['items'] = FONT_SIZES.map(({ label, value }) => ({ + key: value, + label: onFormat('fontSize', value)}>{label}, + })); + + 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 ( + e.stopPropagation()} + > + {/* Color picker */} + +
+ + + + + {/* Font size */} + + + Aa + {currentFontSize} + + + + + + {/* Bold */} +
From ac86bbd60c780f2c3ebc1d1a4e6be78297043b59 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 9 Mar 2026 17:35:56 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(web):=20=E8=B0=83=E6=95=B4=E4=BE=BF?= =?UTF-8?q?=E7=AD=BE=E8=8A=82=E7=82=B9=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/views/Workflow/hooks/useWorkflowGraph.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/web/src/views/Workflow/hooks/useWorkflowGraph.ts b/web/src/views/Workflow/hooks/useWorkflowGraph.ts index e654e4e9..db792c59 100644 --- a/web/src/views/Workflow/hooks/useWorkflowGraph.ts +++ b/web/src/views/Workflow/hooks/useWorkflowGraph.ts @@ -1152,12 +1152,15 @@ export const useWorkflowGraph = ({ name: t('workflow.notes'), ...nodeConfig, }; - const area = graphRef.current.getGraphArea(); - const pos = graphRef.current.graphToLocal(area.center.x, area.bottom); + 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: pos.x - nodeWidth, - y: pos.y, + x: center.x - nodeW / 2, + y: center.y - nodeH / 2, id: cleanNodeData.id, data: { ...cleanNodeData }, });