Explorar el Código

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
Louis Young hace 8 meses
padre
commit
88f7ccae37
Se han modificado 29 ficheros con 1171 adiciones y 18 borrados
  1. 19 0
      apps/demo-free-layout/src/assets/icon-comment.tsx
  2. 43 0
      apps/demo-free-layout/src/components/comment/components/blank-area.tsx
  3. 115 0
      apps/demo-free-layout/src/components/comment/components/border-area.tsx
  4. 47 0
      apps/demo-free-layout/src/components/comment/components/container.tsx
  5. 89 0
      apps/demo-free-layout/src/components/comment/components/content-drag-area.tsx
  6. 40 0
      apps/demo-free-layout/src/components/comment/components/drag-area.tsx
  7. 71 0
      apps/demo-free-layout/src/components/comment/components/editor.tsx
  8. 70 0
      apps/demo-free-layout/src/components/comment/components/index.css
  9. 3 0
      apps/demo-free-layout/src/components/comment/components/index.ts
  10. 78 0
      apps/demo-free-layout/src/components/comment/components/render.tsx
  11. 73 0
      apps/demo-free-layout/src/components/comment/components/resize-area.tsx
  12. 20 0
      apps/demo-free-layout/src/components/comment/constant.ts
  13. 1 0
      apps/demo-free-layout/src/components/comment/hooks/index.ts
  14. 50 0
      apps/demo-free-layout/src/components/comment/hooks/use-model.ts
  15. 45 0
      apps/demo-free-layout/src/components/comment/hooks/use-overflow.ts
  16. 163 0
      apps/demo-free-layout/src/components/comment/hooks/use-size.ts
  17. 1 0
      apps/demo-free-layout/src/components/comment/index.ts
  18. 83 0
      apps/demo-free-layout/src/components/comment/model.ts
  19. 24 0
      apps/demo-free-layout/src/components/comment/type.ts
  20. 1 0
      apps/demo-free-layout/src/components/index.ts
  21. 5 3
      apps/demo-free-layout/src/components/node-panel/node-list.tsx
  22. 76 0
      apps/demo-free-layout/src/components/tools/comment.tsx
  23. 2 0
      apps/demo-free-layout/src/components/tools/index.tsx
  24. 17 13
      apps/demo-free-layout/src/components/tools/readonly.tsx
  25. 5 1
      apps/demo-free-layout/src/hooks/use-editor-props.tsx
  26. 21 0
      apps/demo-free-layout/src/nodes/comment/index.tsx
  27. 1 0
      apps/demo-free-layout/src/nodes/constants.ts
  28. 7 0
      apps/demo-free-layout/src/nodes/index.ts
  29. 1 1
      apps/demo-free-layout/src/typings/node.ts

+ 19 - 0
apps/demo-free-layout/src/assets/icon-comment.tsx

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

+ 43 - 0
apps/demo-free-layout/src/components/comment/components/blank-area.tsx

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

+ 115 - 0
apps/demo-free-layout/src/components/comment/components/border-area.tsx

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

+ 47 - 0
apps/demo-free-layout/src/components/comment/components/container.tsx

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

+ 89 - 0
apps/demo-free-layout/src/components/comment/components/content-drag-area.tsx

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

+ 40 - 0
apps/demo-free-layout/src/components/comment/components/drag-area.tsx

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

+ 71 - 0
apps/demo-free-layout/src/components/comment/components/editor.tsx

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

+ 70 - 0
apps/demo-free-layout/src/components/comment/components/index.css

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

+ 3 - 0
apps/demo-free-layout/src/components/comment/components/index.ts

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

+ 78 - 0
apps/demo-free-layout/src/components/comment/components/render.tsx

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

+ 73 - 0
apps/demo-free-layout/src/components/comment/components/resize-area.tsx

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

+ 20 - 0
apps/demo-free-layout/src/components/comment/constant.ts

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

+ 1 - 0
apps/demo-free-layout/src/components/comment/hooks/index.ts

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

+ 50 - 0
apps/demo-free-layout/src/components/comment/hooks/use-model.ts

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

+ 45 - 0
apps/demo-free-layout/src/components/comment/hooks/use-overflow.ts

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

+ 163 - 0
apps/demo-free-layout/src/components/comment/hooks/use-size.ts

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

+ 1 - 0
apps/demo-free-layout/src/components/comment/index.ts

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

+ 83 - 0
apps/demo-free-layout/src/components/comment/model.ts

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

+ 24 - 0
apps/demo-free-layout/src/components/comment/type.ts

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

+ 1 - 0
apps/demo-free-layout/src/components/index.ts

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

+ 5 - 3
apps/demo-free-layout/src/components/node-panel/node-list.tsx

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

+ 76 - 0
apps/demo-free-layout/src/components/tools/comment.tsx

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

+ 2 - 0
apps/demo-free-layout/src/components/tools/index.tsx

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

+ 17 - 13
apps/demo-free-layout/src/components/tools/readonly.tsx

@@ -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 ? (
-    <IconButton
-      theme="borderless"
-      type="tertiary"
-      icon={<IconLock size="default" />}
-      onClick={toggleReadonly}
-    />
+    <Tooltip content="Editable">
+      <IconButton
+        theme="borderless"
+        type="tertiary"
+        icon={<IconLock size="default" />}
+        onClick={toggleReadonly}
+      />
+    </Tooltip>
   ) : (
-    <IconButton
-      theme="borderless"
-      type="tertiary"
-      icon={<IconUnlock size="default" />}
-      onClick={toggleReadonly}
-    />
+    <Tooltip content="Readonly">
+      <IconButton
+        theme="borderless"
+        type="tertiary"
+        icon={<IconUnlock size="default" />}
+        onClick={toggleReadonly}
+      />
+    </Tooltip>
   );
 };

+ 5 - 1
apps/demo-free-layout/src/hooks/use-editor-props.tsx

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

+ 21 - 0
apps/demo-free-layout/src/nodes/comment/index.tsx

@@ -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 节点没有输出
+};

+ 1 - 0
apps/demo-free-layout/src/nodes/constants.ts

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

+ 7 - 0
apps/demo-free-layout/src/nodes/index.ts

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

+ 1 - 1
apps/demo-free-layout/src/typings/node.ts

@@ -46,7 +46,7 @@ export interface FlowNodeJSON extends FlowNodeJSONDefault {
  * 你可以自定义节点的注册器
  */
 export interface FlowNodeRegistry extends FlowNodeRegistryDefault {
-  info: {
+  info?: {
     icon: string;
     description: string;
   };