From 88f7ccae37f3ee77c032b0bbc46dbbadc4884a06 Mon Sep 17 00:00:00 2001 From: Louis Young <63398145+louisyoungx@users.noreply.github.com> Date: Tue, 22 Apr 2025 18:10:22 +0800 Subject: [PATCH] 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 --- .../src/assets/icon-comment.tsx | 19 ++ .../comment/components/blank-area.tsx | 43 +++++ .../comment/components/border-area.tsx | 115 ++++++++++++ .../comment/components/container.tsx | 47 +++++ .../comment/components/content-drag-area.tsx | 89 ++++++++++ .../comment/components/drag-area.tsx | 40 +++++ .../components/comment/components/editor.tsx | 71 ++++++++ .../components/comment/components/index.css | 70 ++++++++ .../components/comment/components/index.ts | 3 + .../components/comment/components/render.tsx | 78 +++++++++ .../comment/components/resize-area.tsx | 73 ++++++++ .../src/components/comment/constant.ts | 20 +++ .../src/components/comment/hooks/index.ts | 1 + .../src/components/comment/hooks/use-model.ts | 50 ++++++ .../components/comment/hooks/use-overflow.ts | 45 +++++ .../src/components/comment/hooks/use-size.ts | 163 ++++++++++++++++++ .../src/components/comment/index.ts | 1 + .../src/components/comment/model.ts | 83 +++++++++ .../src/components/comment/type.ts | 24 +++ apps/demo-free-layout/src/components/index.ts | 1 + .../src/components/node-panel/node-list.tsx | 8 +- .../src/components/tools/comment.tsx | 76 ++++++++ .../src/components/tools/index.tsx | 2 + .../src/components/tools/readonly.tsx | 30 ++-- .../src/hooks/use-editor-props.tsx | 6 +- .../src/nodes/comment/index.tsx | 21 +++ apps/demo-free-layout/src/nodes/constants.ts | 1 + apps/demo-free-layout/src/nodes/index.ts | 7 + apps/demo-free-layout/src/typings/node.ts | 2 +- 29 files changed, 1171 insertions(+), 18 deletions(-) create mode 100644 apps/demo-free-layout/src/assets/icon-comment.tsx create mode 100644 apps/demo-free-layout/src/components/comment/components/blank-area.tsx create mode 100644 apps/demo-free-layout/src/components/comment/components/border-area.tsx create mode 100644 apps/demo-free-layout/src/components/comment/components/container.tsx create mode 100644 apps/demo-free-layout/src/components/comment/components/content-drag-area.tsx create mode 100644 apps/demo-free-layout/src/components/comment/components/drag-area.tsx create mode 100644 apps/demo-free-layout/src/components/comment/components/editor.tsx create mode 100644 apps/demo-free-layout/src/components/comment/components/index.css create mode 100644 apps/demo-free-layout/src/components/comment/components/index.ts create mode 100644 apps/demo-free-layout/src/components/comment/components/render.tsx create mode 100644 apps/demo-free-layout/src/components/comment/components/resize-area.tsx create mode 100644 apps/demo-free-layout/src/components/comment/constant.ts create mode 100644 apps/demo-free-layout/src/components/comment/hooks/index.ts create mode 100644 apps/demo-free-layout/src/components/comment/hooks/use-model.ts create mode 100644 apps/demo-free-layout/src/components/comment/hooks/use-overflow.ts create mode 100644 apps/demo-free-layout/src/components/comment/hooks/use-size.ts create mode 100644 apps/demo-free-layout/src/components/comment/index.ts create mode 100644 apps/demo-free-layout/src/components/comment/model.ts create mode 100644 apps/demo-free-layout/src/components/comment/type.ts create mode 100644 apps/demo-free-layout/src/components/tools/comment.tsx create mode 100644 apps/demo-free-layout/src/nodes/comment/index.tsx diff --git a/apps/demo-free-layout/src/assets/icon-comment.tsx b/apps/demo-free-layout/src/assets/icon-comment.tsx new file mode 100644 index 00000000..3eede9e6 --- /dev/null +++ b/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 = ({ style }) => ( + + + + +); diff --git a/apps/demo-free-layout/src/components/comment/components/blank-area.tsx b/apps/demo-free-layout/src/components/comment/components/blank-area.tsx new file mode 100644 index 00000000..9e24e48a --- /dev/null +++ b/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 = (props) => { + const { model } = props; + const playground = usePlayground(); + const { selectNode } = useNodeRender(); + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + model.setFocus(false); + selectNode(e); + playground.node.focus(); // 防止节点无法被删除 + }} + onClick={(e) => { + model.setFocus(true); + model.selectEnd(); + }} + > + +
+ ); +}; diff --git a/apps/demo-free-layout/src/components/comment/components/border-area.tsx b/apps/demo-free-layout/src/components/comment/components/border-area.tsx new file mode 100644 index 00000000..b60f3f95 --- /dev/null +++ b/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 = (props) => { + const { model, overflow, onResize } = props; + + return ( +
+ {/* 左边 */} + + {/* 右边 */} + + {/* 上边 */} + + {/* 下边 */} + + {/** 左上角 */} + ({ top: y, right: 0, bottom: 0, left: x })} + onResize={onResize} + /> + {/** 右上角 */} + ({ top: y, right: x, bottom: 0, left: 0 })} + onResize={onResize} + /> + {/** 右下角 */} + ({ top: 0, right: x, bottom: y, left: 0 })} + onResize={onResize} + /> + {/** 左下角 */} + ({ top: 0, right: 0, bottom: y, left: x })} + onResize={onResize} + /> +
+ ); +}; diff --git a/apps/demo-free-layout/src/components/comment/components/container.tsx b/apps/demo-free-layout/src/components/comment/components/container.tsx new file mode 100644 index 00000000..364540cb --- /dev/null +++ b/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 = (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 ( +
+ {children} +
+ ); +}; diff --git a/apps/demo-free-layout/src/components/comment/components/content-drag-area.tsx b/apps/demo-free-layout/src/components/comment/components/content-drag-area.tsx new file mode 100644 index 00000000..37b42031 --- /dev/null +++ b/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 = (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 = (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 ( +
+ +
+ ); +}; diff --git a/apps/demo-free-layout/src/components/comment/components/drag-area.tsx b/apps/demo-free-layout/src/components/comment/components/drag-area.tsx new file mode 100644 index 00000000..316e7b7b --- /dev/null +++ b/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 = (props) => { + const { model, stopEvent = true, style } = props; + + const playground = usePlayground(); + + const { startDrag: onStartDrag, onFocus, onBlur, selectNode } = useNodeRender(); + + return ( +
{ + if (stopEvent) { + e.preventDefault(); + e.stopPropagation(); + } + model.setFocus(false); + onStartDrag(e); + selectNode(e); + playground.node.focus(); // 防止节点无法被删除 + }} + onFocus={onFocus} + onBlur={onBlur} + /> + ); +}; diff --git a/apps/demo-free-layout/src/components/comment/components/editor.tsx b/apps/demo-free-layout/src/components/comment/components/editor.tsx new file mode 100644 index 00000000..3d393864 --- /dev/null +++ b/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 = (props) => { + const { model, style, onChange } = props; + const playground = usePlayground(); + const editorRef = useRef(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 ( +
+

{placeholder}

+