feat(free-demo): support comment node (#165)

* feat(demo): comment node render component

* feat(demo): comment node register to editor

* feat(demo): comment node editor component

* feat(demo): comment node editor placeholder

* feat(demo): toolbar create comment node

* fix(demo): scrolling issue when comment loses focus
This commit is contained in:
Louis Young 2025-04-22 18:10:22 +08:00 committed by GitHub
parent 0bd90c2fce
commit 88f7ccae37
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 1171 additions and 18 deletions

View File

@ -0,0 +1,19 @@
import { CSSProperties, FC } from 'react';
interface IconCommentProps {
style?: CSSProperties;
}
export const IconComment: FC<IconCommentProps> = ({ style }) => (
<svg
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
style={style}
>
<path d="M6.5 9C5.94772 9 5.5 9.44772 5.5 10V11C5.5 11.5523 5.94772 12 6.5 12H7.5C8.05228 12 8.5 11.5523 8.5 11V10C8.5 9.44772 8.05228 9 7.5 9H6.5zM11.5 9C10.9477 9 10.5 9.44772 10.5 10V11C10.5 11.5523 10.9477 12 11.5 12H12.5C13.0523 12 13.5 11.5523 13.5 11V10C13.5 9.44772 13.0523 9 12.5 9H11.5zM15.5 10C15.5 9.44772 15.9477 9 16.5 9H17.5C18.0523 9 18.5 9.44772 18.5 10V11C18.5 11.5523 18.0523 12 17.5 12H16.5C15.9477 12 15.5 11.5523 15.5 11V10z"></path>
<path d="M23 4C23 2.9 22.1 2 21 2H3C1.9 2 1 2.9 1 4V17.0111C1 18.0211 1.9 19.0111 3 19.0111H7.7586L10.4774 22C10.9822 22.5017 11.3166 22.6311 12 22.7009C12.414 22.707 13.0502 22.5093 13.5 22L16.2414 19.0111H21C22.1 19.0111 23 18.1111 23 17.0111V4ZM3 4H21V17.0111H15.5L12 20.6714L8.5 17.0111H3V4Z"></path>
</svg>
);

View File

@ -0,0 +1,43 @@
import type { FC } from 'react';
import { useNodeRender, usePlayground } from '@flowgram.ai/free-layout-editor';
import type { CommentEditorModel } from '../model';
import { DragArea } from './drag-area';
interface IBlankArea {
model: CommentEditorModel;
}
export const BlankArea: FC<IBlankArea> = (props) => {
const { model } = props;
const playground = usePlayground();
const { selectNode } = useNodeRender();
return (
<div
className="workflow-comment-blank-area h-full w-full"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
model.setFocus(false);
selectNode(e);
playground.node.focus(); // 防止节点无法被删除
}}
onClick={(e) => {
model.setFocus(true);
model.selectEnd();
}}
>
<DragArea
style={{
position: 'relative',
width: '100%',
height: '100%',
}}
model={model}
stopEvent={false}
/>
</div>
);
};

View File

@ -0,0 +1,115 @@
import { type FC } from 'react';
import type { CommentEditorModel } from '../model';
import { ResizeArea } from './resize-area';
import { DragArea } from './drag-area';
interface IBorderArea {
model: CommentEditorModel;
overflow: boolean;
onResize?: () => {
resizing: (delta: { top: number; right: number; bottom: number; left: number }) => void;
resizeEnd: () => void;
};
}
export const BorderArea: FC<IBorderArea> = (props) => {
const { model, overflow, onResize } = props;
return (
<div style={{ zIndex: 999 }}>
{/* 左边 */}
<DragArea
style={{
position: 'absolute',
left: -10,
top: 10,
width: 20,
height: 'calc(100% - 20px)',
}}
model={model}
/>
{/* 右边 */}
<DragArea
style={{
position: 'absolute',
right: -10,
top: 10,
height: 'calc(100% - 20px)',
width: overflow ? 10 : 20, // 防止遮挡滚动条
}}
model={model}
/>
{/* 上边 */}
<DragArea
style={{
position: 'absolute',
top: -10,
left: 10,
width: 'calc(100% - 20px)',
height: 20,
}}
model={model}
/>
{/* 下边 */}
<DragArea
style={{
position: 'absolute',
bottom: -10,
left: 10,
width: 'calc(100% - 20px)',
height: 20,
}}
model={model}
/>
{/** 左上角 */}
<ResizeArea
style={{
position: 'absolute',
left: 0,
top: 0,
cursor: 'nwse-resize',
}}
model={model}
getDelta={({ x, y }) => ({ top: y, right: 0, bottom: 0, left: x })}
onResize={onResize}
/>
{/** 右上角 */}
<ResizeArea
style={{
position: 'absolute',
right: 0,
top: 0,
cursor: 'nesw-resize',
}}
model={model}
getDelta={({ x, y }) => ({ top: y, right: x, bottom: 0, left: 0 })}
onResize={onResize}
/>
{/** 右下角 */}
<ResizeArea
style={{
position: 'absolute',
right: 0,
bottom: 0,
cursor: 'nwse-resize',
}}
model={model}
getDelta={({ x, y }) => ({ top: 0, right: x, bottom: y, left: 0 })}
onResize={onResize}
/>
{/** 左下角 */}
<ResizeArea
style={{
position: 'absolute',
left: 0,
bottom: 0,
cursor: 'nesw-resize',
}}
model={model}
getDelta={({ x, y }) => ({ top: 0, right: 0, bottom: y, left: x })}
onResize={onResize}
/>
</div>
);
};

View File

@ -0,0 +1,47 @@
import type { ReactNode, FC, CSSProperties } from 'react';
interface ICommentContainer {
focused: boolean;
overflow: boolean;
children?: ReactNode;
style?: React.CSSProperties;
}
export const CommentContainer: FC<ICommentContainer> = (props) => {
const { focused, overflow, children, style } = props;
const scrollbarStyle = {
// 滚动条样式
scrollbarWidth: 'thin',
scrollbarColor: 'rgb(159 159 158 / 65%) transparent',
// 针对 WebKit 浏览器(如 Chrome、Safari的样式
'&::-webkit-scrollbar': {
width: '4px',
},
'&::-webkit-scrollbar-track': {
background: 'transparent',
},
'&::-webkit-scrollbar-thumb': {
backgroundColor: 'rgb(159 159 158 / 65%)',
borderRadius: '20px',
border: '2px solid transparent',
},
} as unknown as CSSProperties;
return (
<div
className="workflow-comment-container"
data-flow-editor-selectable="false"
style={{
// tailwind 不支持 outline 的样式,所以这里需要使用 style 来设置
outline: focused ? '1px solid #FF811A' : '1px solid #F2B600',
backgroundColor: focused ? '#FFF3EA' : '#FFFBED',
// paddingRight: overflow ? 0 : undefined,
...scrollbarStyle,
...style,
}}
>
{children}
</div>
);
};

View File

@ -0,0 +1,89 @@
import { type FC, useState, useEffect, type WheelEventHandler } from 'react';
import { useNodeRender, usePlayground } from '@flowgram.ai/free-layout-editor';
import type { CommentEditorModel } from '../model';
import { DragArea } from './drag-area';
interface IContentDragArea {
model: CommentEditorModel;
focused: boolean;
overflow: boolean;
}
export const ContentDragArea: FC<IContentDragArea> = (props) => {
const { model, focused, overflow } = props;
const playground = usePlayground();
const { selectNode } = useNodeRender();
const [active, setActive] = useState(false);
useEffect(() => {
// 当编辑器失去焦点时,取消激活状态
if (!focused) {
setActive(false);
}
}, [focused]);
const handleWheel: WheelEventHandler<HTMLDivElement> = (e) => {
const editorElement = model.element;
if (active || !overflow || !editorElement) {
return;
}
e.stopPropagation();
const maxScroll = editorElement.scrollHeight - editorElement.clientHeight;
const newScrollTop = Math.min(Math.max(editorElement.scrollTop + e.deltaY, 0), maxScroll);
editorElement.scroll(0, newScrollTop);
};
const handleMouseDown = (mouseDownEvent: React.MouseEvent) => {
if (active) {
return;
}
mouseDownEvent.preventDefault();
mouseDownEvent.stopPropagation();
model.setFocus(false);
selectNode(mouseDownEvent);
playground.node.focus(); // 防止节点无法被删除
const startX = mouseDownEvent.clientX;
const startY = mouseDownEvent.clientY;
const handleMouseUp = (mouseMoveEvent: MouseEvent) => {
const deltaX = mouseMoveEvent.clientX - startX;
const deltaY = mouseMoveEvent.clientY - startY;
// 判断是拖拽还是点击
const delta = 5;
if (Math.abs(deltaX) < delta && Math.abs(deltaY) < delta) {
// 点击后隐藏
setActive(true);
}
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('click', handleMouseUp);
};
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('click', handleMouseUp);
};
return (
<div
className="workflow-comment-content-drag-area"
onMouseDown={handleMouseDown}
onWheel={handleWheel}
style={{
display: active ? 'none' : undefined,
}}
>
<DragArea
style={{
position: 'relative',
width: '100%',
height: '100%',
}}
model={model}
stopEvent={false}
/>
</div>
);
};

View File

@ -0,0 +1,40 @@
import { CSSProperties, type FC } from 'react';
import { useNodeRender, usePlayground } from '@flowgram.ai/free-layout-editor';
import { type CommentEditorModel } from '../model';
interface IDragArea {
model: CommentEditorModel;
stopEvent?: boolean;
style?: CSSProperties;
}
export const DragArea: FC<IDragArea> = (props) => {
const { model, stopEvent = true, style } = props;
const playground = usePlayground();
const { startDrag: onStartDrag, onFocus, onBlur, selectNode } = useNodeRender();
return (
<div
className="workflow-comment-drag-area"
data-flow-editor-selectable="false"
draggable={true}
style={style}
onMouseDown={(e) => {
if (stopEvent) {
e.preventDefault();
e.stopPropagation();
}
model.setFocus(false);
onStartDrag(e);
selectNode(e);
playground.node.focus(); // 防止节点无法被删除
}}
onFocus={onFocus}
onBlur={onBlur}
/>
);
};

View File

@ -0,0 +1,71 @@
import { type FC, type CSSProperties, useEffect, useRef, useState, useMemo } from 'react';
import { usePlayground } from '@flowgram.ai/free-layout-editor';
import { CommentEditorModel } from '../model';
import { CommentEditorEvent } from '../constant';
interface ICommentEditor {
model: CommentEditorModel;
style?: CSSProperties;
value?: string;
onChange?: (value: string) => void;
}
export const CommentEditor: FC<ICommentEditor> = (props) => {
const { model, style, onChange } = props;
const playground = usePlayground();
const editorRef = useRef<HTMLTextAreaElement | null>(null);
const [value, setValue] = useState(model.value);
const [focused, setFocus] = useState(false);
const placeholder: string | undefined = useMemo(() => {
if (value || focused) {
return;
}
return 'Enter a comment...';
}, [value, focused]);
// 同步编辑器内部值变化
useEffect(() => {
const disposer = model.on((params) => {
if (params.type !== CommentEditorEvent.Change) {
return;
}
onChange?.(model.value);
});
return () => disposer.dispose();
}, [model, onChange]);
useEffect(() => {
if (!editorRef.current) {
return;
}
model.element = editorRef.current;
}, [editorRef]);
return (
<div className="workflow-comment-editor">
<p className="workflow-comment-editor-placeholder">{placeholder}</p>
<textarea
className="workflow-comment-editor-textarea"
ref={editorRef}
style={style}
readOnly={playground.config.readonly}
onChange={(e) => {
const { value } = e.target;
model.setValue(value);
setValue(value);
}}
onFocus={() => {
model.setFocus(true);
setFocus(true);
}}
onBlur={() => {
model.setFocus(false);
setFocus(false);
}}
/>
</div>
);
};

View File

@ -0,0 +1,70 @@
.workflow-comment {
width: auto;
height: auto;
min-width: 120px;
min-height: 80px;
}
.workflow-comment-container {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
width: 100%;
height: 100%;
border-radius: 8px;
outline: 1px solid;
padding: 6px 2px 6px 10px;
overflow-y: auto;
overflow-x: hidden;
}
.workflow-comment-drag-area {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
cursor: move;
}
.workflow-comment-content-drag-area {
position: absolute;
height: 100%;
width: calc(100% - 22px);
}
.workflow-comment-resize-area {
position: absolute;
width: 10px;
height: 10px;
}
.workflow-comment-editor {
width: 100%;
height: 100%;
}
.workflow-comment-editor-placeholder {
margin: 0;
position: absolute;
pointer-events: none;
color: rgba(55, 67, 106, 0.38);
font-weight: 500;
}
.workflow-comment-editor-textarea {
width: 100%;
height: 100%;
box-sizing: border-box;
appearance: none;
border: none;
margin: 0;
padding: 0;
width: 100%;
background: none;
color: inherit;
font-family: inherit;
font-size: 16px;
resize: none;
outline: none;
}

View File

@ -0,0 +1,3 @@
import './index.css';
export { CommentRender } from './render';

View File

@ -0,0 +1,78 @@
import { FC } from 'react';
import {
Field,
FieldRenderProps,
FlowNodeFormData,
Form,
FormModelV2,
useNodeRender,
WorkflowNodeEntity,
} from '@flowgram.ai/free-layout-editor';
import { useOverflow } from '../hooks/use-overflow';
import { useModel } from '../hooks/use-model';
import { useSize } from '../hooks';
import { CommentEditorFormField } from '../constant';
import { CommentEditor } from './editor';
import { ContentDragArea } from './content-drag-area';
import { CommentContainer } from './container';
import { BorderArea } from './border-area';
import { BlankArea } from './blank-area';
export const CommentRender: FC<{
node: WorkflowNodeEntity;
}> = (props) => {
const { node } = props;
const model = useModel();
const { selected: focused, selectNode, nodeRef } = useNodeRender();
const formModel = node.getData(FlowNodeFormData).getFormModel<FormModelV2>();
const formControl = formModel?.formControl;
const { width, height, onResize } = useSize();
const { overflow, updateOverflow } = useOverflow({ model, height });
return (
<div
className="workflow-comment"
style={{
width,
height,
}}
ref={nodeRef}
data-node-selected={String(focused)}
onMouseEnter={updateOverflow}
onMouseDown={(e) => {
setTimeout(() => {
// 防止 selectNode 拦截事件,导致 slate 编辑器无法聚焦
selectNode(e);
// eslint-disable-next-line @typescript-eslint/no-magic-numbers -- delay
}, 20);
}}
>
<Form control={formControl}>
<>
{/* 背景 */}
<CommentContainer focused={focused} overflow={overflow} style={{ height }}>
<Field name={CommentEditorFormField.Note}>
{({ field }: FieldRenderProps<string>) => (
<>
{/** 编辑器 */}
<CommentEditor model={model} value={field.value} onChange={field.onChange} />
{/* 空白区域 */}
<BlankArea model={model} />
{/* 内容拖拽区域(点击后隐藏) */}
<ContentDragArea model={model} focused={focused} overflow={overflow} />
</>
)}
</Field>
</CommentContainer>
{/* 边框 */}
<BorderArea model={model} overflow={overflow} onResize={onResize} />
</>
</Form>
</div>
);
};

View File

@ -0,0 +1,73 @@
import { CSSProperties, type FC } from 'react';
import { useNodeRender, usePlayground } from '@flowgram.ai/free-layout-editor';
import type { CommentEditorModel } from '../model';
interface IResizeArea {
model: CommentEditorModel;
onResize?: () => {
resizing: (delta: { top: number; right: number; bottom: number; left: number }) => void;
resizeEnd: () => void;
};
getDelta?: (delta: { x: number; y: number }) => {
top: number;
right: number;
bottom: number;
left: number;
};
style?: CSSProperties;
}
export const ResizeArea: FC<IResizeArea> = (props) => {
const { model, onResize, getDelta, style } = props;
const playground = usePlayground();
const { selectNode } = useNodeRender();
const handleMouseDown = (mouseDownEvent: React.MouseEvent) => {
mouseDownEvent.preventDefault();
mouseDownEvent.stopPropagation();
if (!onResize) {
return;
}
const { resizing, resizeEnd } = onResize();
model.setFocus(false);
selectNode(mouseDownEvent);
playground.node.focus(); // 防止节点无法被删除
const startX = mouseDownEvent.clientX;
const startY = mouseDownEvent.clientY;
const handleMouseMove = (mouseMoveEvent: MouseEvent) => {
const deltaX = mouseMoveEvent.clientX - startX;
const deltaY = mouseMoveEvent.clientY - startY;
const delta = getDelta?.({ x: deltaX, y: deltaY });
if (!delta || !resizing) {
return;
}
resizing(delta);
};
const handleMouseUp = () => {
resizeEnd();
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('click', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('click', handleMouseUp);
};
return (
<div
className="workflow-comment-resize-area"
style={style}
data-flow-editor-selectable="false"
onMouseDown={handleMouseDown}
/>
);
};

View File

@ -0,0 +1,20 @@
/* eslint-disable @typescript-eslint/naming-convention -- enum */
export enum CommentEditorFormField {
Size = 'size',
Note = 'note',
}
/** 编辑器事件 */
export enum CommentEditorEvent {
/** 内容变更事件 */
Change = 'change',
/** 多选事件 */
MultiSelect = 'multiSelect',
/** 单选事件 */
Select = 'select',
/** 失焦事件 */
Blur = 'blur',
}
export const CommentEditorDefaultValue = '';

View File

@ -0,0 +1 @@
export { useSize } from './use-size';

View File

@ -0,0 +1,50 @@
import { useEffect, useMemo } from 'react';
import {
FlowNodeFormData,
FormModelV2,
useEntityFromContext,
useNodeRender,
WorkflowNodeEntity,
} from '@flowgram.ai/free-layout-editor';
import { CommentEditorModel } from '../model';
import { CommentEditorFormField } from '../constant';
export const useModel = () => {
const node = useEntityFromContext<WorkflowNodeEntity>();
const { selected: focused } = useNodeRender();
const formModel = node.getData(FlowNodeFormData).getFormModel<FormModelV2>();
const model = useMemo(() => new CommentEditorModel(), []);
// 同步失焦状态
useEffect(() => {
if (focused) {
return;
}
model.setFocus(focused);
}, [focused, model]);
// 同步表单值初始化
useEffect(() => {
const value = formModel.getValueIn<string>(CommentEditorFormField.Note);
model.setValue(value); // 设置初始值
model.selectEnd(); // 设置初始化光标位置
}, [formModel, model]);
// 同步表单外部值变化undo/redo/协同
useEffect(() => {
const disposer = formModel.onFormValuesChange(({ name }) => {
if (name !== CommentEditorFormField.Note) {
return;
}
const value = formModel.getValueIn<string>(CommentEditorFormField.Note);
model.setValue(value);
});
return () => disposer.dispose();
}, [formModel, model]);
return model;
};

View File

@ -0,0 +1,45 @@
import { useCallback, useState, useEffect } from 'react';
import { usePlayground } from '@flowgram.ai/free-layout-editor';
import { CommentEditorModel } from '../model';
import { CommentEditorEvent } from '../constant';
export const useOverflow = (params: { model: CommentEditorModel; height: number }) => {
const { model, height } = params;
const playground = usePlayground();
const [overflow, setOverflow] = useState(false);
const isOverflow = useCallback((): boolean => {
if (!model.element) {
return false;
}
return model.element.scrollHeight > model.element.clientHeight;
}, [model, height, playground]);
// 更新 overflow
const updateOverflow = useCallback(() => {
setOverflow(isOverflow());
}, [isOverflow]);
// 监听高度变化
useEffect(() => {
updateOverflow();
}, [height, updateOverflow]);
// 监听 change 事件
useEffect(() => {
const changeDisposer = model.on((params) => {
if (params.type !== CommentEditorEvent.Change) {
return;
}
updateOverflow();
});
return () => {
changeDisposer.dispose();
};
}, [model, updateOverflow]);
return { overflow, updateOverflow };
};

View File

@ -0,0 +1,163 @@
import { useCallback, useEffect, useState } from 'react';
import {
FlowNodeFormData,
FormModelV2,
FreeOperationType,
HistoryService,
TransformData,
useCurrentEntity,
usePlayground,
useService,
} from '@flowgram.ai/free-layout-editor';
import { CommentEditorFormField } from '../constant';
export const useSize = () => {
const node = useCurrentEntity();
const nodeMeta = node.getNodeMeta();
const playground = usePlayground();
const historyService = useService(HistoryService);
const { size = { width: 240, height: 150 } } = nodeMeta;
const transform = node.getData(TransformData);
const formModel = node.getData(FlowNodeFormData).getFormModel<FormModelV2>();
const formSize = formModel.getValueIn<{ width: number; height: number }>(
CommentEditorFormField.Size
);
const [width, setWidth] = useState(formSize?.width ?? size.width);
const [height, setHeight] = useState(formSize?.height ?? size.height);
// 初始化表单值
useEffect(() => {
const initSize = formModel.getValueIn<{ width: number; height: number }>(
CommentEditorFormField.Size
);
if (!initSize) {
formModel.setValueIn(CommentEditorFormField.Size, {
width,
height,
});
}
}, [formModel, width, height]);
// 同步表单外部值变化:初始化/undo/redo/协同
useEffect(() => {
const disposer = formModel.onFormValuesChange(({ name }) => {
if (name !== CommentEditorFormField.Size) {
return;
}
const newSize = formModel.getValueIn<{ width: number; height: number }>(
CommentEditorFormField.Size
);
if (!newSize) {
return;
}
setWidth(newSize.width);
setHeight(newSize.height);
});
return () => disposer.dispose();
}, [formModel]);
const onResize = useCallback(() => {
const resizeState = {
width,
height,
originalWidth: width,
originalHeight: height,
positionX: transform.position.x,
positionY: transform.position.y,
offsetX: 0,
offsetY: 0,
};
const resizing = (delta: { top: number; right: number; bottom: number; left: number }) => {
if (!resizeState) {
return;
}
const { zoom } = playground.config;
const top = delta.top / zoom;
const right = delta.right / zoom;
const bottom = delta.bottom / zoom;
const left = delta.left / zoom;
const minWidth = 120;
const minHeight = 80;
const newWidth = Math.max(minWidth, resizeState.originalWidth + right - left);
const newHeight = Math.max(minHeight, resizeState.originalHeight + bottom - top);
// 如果宽度或高度小于最小值,则不更新偏移量
const newOffsetX =
(left > 0 || right < 0) && newWidth <= minWidth
? resizeState.offsetX
: left / 2 + right / 2;
const newOffsetY =
(top > 0 || bottom < 0) && newHeight <= minHeight ? resizeState.offsetY : top;
const newPositionX = resizeState.positionX + newOffsetX;
const newPositionY = resizeState.positionY + newOffsetY;
resizeState.width = newWidth;
resizeState.height = newHeight;
resizeState.offsetX = newOffsetX;
resizeState.offsetY = newOffsetY;
// 更新状态
setWidth(newWidth);
setHeight(newHeight);
// 更新偏移量
transform.update({
position: {
x: newPositionX,
y: newPositionY,
},
});
};
const resizeEnd = () => {
historyService.transact(() => {
historyService.pushOperation(
{
type: FreeOperationType.dragNodes,
value: {
ids: [node.id],
value: [
{
x: resizeState.positionX + resizeState.offsetX,
y: resizeState.positionY + resizeState.offsetY,
},
],
oldValue: [
{
x: resizeState.positionX,
y: resizeState.positionY,
},
],
},
},
{
noApply: true,
}
);
formModel.setValueIn(CommentEditorFormField.Size, {
width: resizeState.width,
height: resizeState.height,
});
});
};
return {
resizing,
resizeEnd,
};
}, [node, width, height, transform, playground, formModel, historyService]);
return {
width,
height,
onResize,
};
};

View File

@ -0,0 +1 @@
export { CommentRender } from './components';

View File

@ -0,0 +1,83 @@
import { Emitter } from '@flowgram.ai/free-layout-editor';
import { CommentEditorEventParams } from './type';
import { CommentEditorDefaultValue, CommentEditorEvent } from './constant';
export class CommentEditorModel {
private innerValue: string = CommentEditorDefaultValue;
private emitter: Emitter<CommentEditorEventParams> = new Emitter();
private editor: HTMLTextAreaElement | null = null;
/** 注册事件 */
public on = this.emitter.event;
/** 获取当前值 */
public get value(): string {
return this.innerValue;
}
/** 外部设置模型值 */
public setValue(value: string = CommentEditorDefaultValue): void {
if (value === this.innerValue) {
return;
}
this.innerValue = value;
this.emitter.fire({
type: CommentEditorEvent.Change,
value: this.innerValue,
});
}
public set element(el: HTMLTextAreaElement) {
if (Boolean(this.editor)) {
return;
}
this.editor = el;
}
/** 获取编辑器 DOM 节点 */
public get element(): HTMLTextAreaElement | null {
return this.editor;
}
/** 编辑器聚焦/失焦 */
public setFocus(focused: boolean): void {
if (focused && !this.focused) {
this.editor?.focus();
} else if (!focused && this.focused) {
this.editor?.blur();
this.deselect();
this.emitter.fire({
type: CommentEditorEvent.Blur,
});
}
}
/** 选择末尾 */
public selectEnd(): void {
if (!this.editor) {
return;
}
// 获取文本长度
const length = this.editor.value.length;
// 将选择范围设置为文本末尾(开始位置和结束位置都是文本长度)
this.editor.setSelectionRange(length, length);
}
/** 获取聚焦状态 */
public get focused(): boolean {
return document.activeElement === this.editor;
}
/** 取消选择文本 */
private deselect(): void {
const selection: Selection | null = window.getSelection();
// 清除所有选择区域
if (selection) {
selection.removeAllRanges();
}
}
}

View File

@ -0,0 +1,24 @@
import type { CommentEditorEvent } from './constant';
interface CommentEditorChangeEvent {
type: CommentEditorEvent.Change;
value: string;
}
interface CommentEditorMultiSelectEvent {
type: CommentEditorEvent.MultiSelect;
}
interface CommentEditorSelectEvent {
type: CommentEditorEvent.Select;
}
interface CommentEditorBlurEvent {
type: CommentEditorEvent.Blur;
}
export type CommentEditorEventParams =
| CommentEditorChangeEvent
| CommentEditorMultiSelectEvent
| CommentEditorSelectEvent
| CommentEditorBlurEvent;

View File

@ -1,3 +1,4 @@
export * from './base-node';
export * from './line-add-button';
export * from './node-panel';
export * from './comment';

View File

@ -5,7 +5,7 @@ import { NodePanelRenderProps } from '@flowgram.ai/free-node-panel-plugin';
import { useClientContext } from '@flowgram.ai/free-layout-editor';
import { FlowNodeRegistry } from '../../typings';
import { nodeRegistries } from '../../nodes';
import { visibleNodeRegistries } from '../../nodes';
const NodeWrap = styled.div`
width: 100%;
@ -71,11 +71,13 @@ export const NodeList: FC<NodeListProps> = (props) => {
};
return (
<NodesWrap style={{ width: 80 * 2 + 20 }}>
{nodeRegistries.map((registry) => (
{visibleNodeRegistries.map((registry) => (
<Node
key={registry.type}
disabled={!(registry.canAdd?.(context) ?? true)}
icon={<img style={{ width: 10, height: 10, borderRadius: 4 }} src={registry.info.icon} />}
icon={
<img style={{ width: 10, height: 10, borderRadius: 4 }} src={registry.info?.icon} />
}
label={registry.type as string}
onClick={(e) => handleClick(e, registry)}
/>

View File

@ -0,0 +1,76 @@
import { useState, useCallback } from 'react';
import {
delay,
usePlayground,
useService,
WorkflowDocument,
WorkflowDragService,
WorkflowSelectService,
} from '@flowgram.ai/free-layout-editor';
import { IconButton, Tooltip } from '@douyinfe/semi-ui';
import { WorkflowNodeType } from '../../nodes';
import { IconComment } from '../../assets/icon-comment';
export const Comment = () => {
const playground = usePlayground();
const document = useService(WorkflowDocument);
const selectService = useService(WorkflowSelectService);
const dragService = useService(WorkflowDragService);
const [tooltipVisible, setTooltipVisible] = useState(false);
const calcNodePosition = useCallback(
(mouseEvent: React.MouseEvent<HTMLButtonElement>) => {
const mousePosition = playground.config.getPosFromMouseEvent(mouseEvent);
return {
x: mousePosition.x,
y: mousePosition.y - 75,
};
},
[playground]
);
const createComment = useCallback(
async (mouseEvent: React.MouseEvent<HTMLButtonElement>) => {
setTooltipVisible(false);
const canvasPosition = calcNodePosition(mouseEvent);
// 创建节点
const node = document.createWorkflowNodeByType(WorkflowNodeType.Comment, canvasPosition);
// 等待节点渲染
await delay(16);
// 选中节点
selectService.selectNode(node);
// 开始拖拽
dragService.startDragSelectedNodes(mouseEvent);
},
[selectService, calcNodePosition, document, dragService]
);
return (
<Tooltip
trigger="custom"
visible={tooltipVisible}
onVisibleChange={setTooltipVisible}
content="Comment"
>
<IconButton
disabled={playground.config.readonly}
icon={
<IconComment
style={{
width: 16,
height: 16,
}}
/>
}
type="tertiary"
theme="borderless"
onClick={createComment}
onMouseEnter={() => setTooltipVisible(true)}
onMouseLeave={() => setTooltipVisible(false)}
/>
</Tooltip>
);
};

View File

@ -15,6 +15,7 @@ import { MinimapSwitch } from './minimap-switch';
import { Minimap } from './minimap';
import { Interactive } from './interactive';
import { FitView } from './fit-view';
import { Comment } from './comment';
import { AutoLayout } from './auto-layout';
export const DemoTools = () => {
@ -47,6 +48,7 @@ export const DemoTools = () => {
<MinimapSwitch minimapVisible={minimapVisible} setMinimapVisible={setMinimapVisible} />
<Minimap visible={minimapVisible} />
<Readonly />
<Comment />
<Tooltip content="Undo">
<IconButton
type="tertiary"

View File

@ -1,7 +1,7 @@
import { useCallback } from 'react';
import { usePlayground } from '@flowgram.ai/free-layout-editor';
import { IconButton } from '@douyinfe/semi-ui';
import { IconButton, Tooltip } from '@douyinfe/semi-ui';
import { IconUnlock, IconLock } from '@douyinfe/semi-icons';
export const Readonly = () => {
@ -10,18 +10,22 @@ export const Readonly = () => {
playground.config.readonly = !playground.config.readonly;
}, [playground]);
return playground.config.readonly ? (
<Tooltip content="Editable">
<IconButton
theme="borderless"
type="tertiary"
icon={<IconLock size="default" />}
onClick={toggleReadonly}
/>
</Tooltip>
) : (
<Tooltip content="Readonly">
<IconButton
theme="borderless"
type="tertiary"
icon={<IconUnlock size="default" />}
onClick={toggleReadonly}
/>
</Tooltip>
);
};

View File

@ -15,8 +15,9 @@ import { shortcuts } from '../shortcuts';
import { CustomService } from '../services';
import { createSyncVariablePlugin } from '../plugins';
import { defaultFormMeta } from '../nodes/default-form-meta';
import { WorkflowNodeType } from '../nodes';
import { SelectorBoxPopover } from '../components/selector-box-popover';
import { BaseNode, LineAddButton, NodePanel } from '../components';
import { BaseNode, CommentRender, LineAddButton, NodePanel } from '../components';
export function useEditorProps(
initialData: FlowDocumentJSON,
@ -104,6 +105,9 @@ export function useEditorProps(
* Render Node
*/
renderDefaultNode: BaseNode,
renderNodes: {
[WorkflowNodeType.Comment]: CommentRender,
},
},
/**
* Node engine enable, you can configure formMeta in the FlowNodeRegistry

View File

@ -0,0 +1,21 @@
import { WorkflowNodeType } from '../constants';
import { FlowNodeRegistry } from '../../typings';
export const CommentNodeRegistry: FlowNodeRegistry = {
type: WorkflowNodeType.Comment,
meta: {
isStart: true,
isNodeEnd: true,
disableSideSheet: true,
renderKey: WorkflowNodeType.Comment,
size: {
width: 240,
height: 150,
},
},
formMeta: {
render: () => <></>,
},
getInputPoints: () => [], // Comment 节点没有输入
getOutputPoints: () => [], // Comment 节点没有输出
};

View File

@ -4,4 +4,5 @@ export enum WorkflowNodeType {
LLM = 'llm',
Condition = 'condition',
Loop = 'loop',
Comment = 'comment',
}

View File

@ -3,7 +3,9 @@ import { StartNodeRegistry } from './start';
import { LoopNodeRegistry } from './loop';
import { LLMNodeRegistry } from './llm';
import { EndNodeRegistry } from './end';
import { WorkflowNodeType } from './constants';
import { ConditionNodeRegistry } from './condition';
import { CommentNodeRegistry } from './comment';
export { WorkflowNodeType } from './constants';
export const nodeRegistries: FlowNodeRegistry[] = [
@ -12,4 +14,9 @@ export const nodeRegistries: FlowNodeRegistry[] = [
EndNodeRegistry,
LLMNodeRegistry,
LoopNodeRegistry,
CommentNodeRegistry,
];
export const visibleNodeRegistries = nodeRegistries.filter(
(r) => r.type !== WorkflowNodeType.Comment
);

View File

@ -46,7 +46,7 @@ export interface FlowNodeJSON extends FlowNodeJSONDefault {
*
*/
export interface FlowNodeRegistry extends FlowNodeRegistryDefault {
info: {
info?: {
icon: string;
description: string;
};