feat(web): 注释节点

This commit is contained in:
zhaoying
2026-03-09 17:30:43 +08:00
parent 00e0201bf9
commit 33d12c43b2
11 changed files with 857 additions and 13 deletions

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;