feat: workflow add note node (#5164)
This commit is contained in:
@@ -12,7 +12,11 @@ import {
|
||||
useStore,
|
||||
useWorkflowStore,
|
||||
} from './store'
|
||||
import { useNodesInteractions } from './hooks'
|
||||
import { CUSTOM_NODE } from './constants'
|
||||
import CustomNode from './nodes'
|
||||
import CustomNoteNode from './note-node'
|
||||
import { CUSTOM_NOTE_NODE } from './note-node/constants'
|
||||
|
||||
const CandidateNode = () => {
|
||||
const store = useStoreApi()
|
||||
@@ -21,6 +25,7 @@ const CandidateNode = () => {
|
||||
const candidateNode = useStore(s => s.candidateNode)
|
||||
const mousePosition = useStore(s => s.mousePosition)
|
||||
const { zoom } = useViewport()
|
||||
const { handleNodeSelect } = useNodesInteractions()
|
||||
|
||||
useEventListener('click', (e) => {
|
||||
const { candidateNode, mousePosition } = workflowStore.getState()
|
||||
@@ -49,6 +54,9 @@ const CandidateNode = () => {
|
||||
})
|
||||
setNodes(newNodes)
|
||||
workflowStore.setState({ candidateNode: undefined })
|
||||
|
||||
if (candidateNode.type === CUSTOM_NOTE_NODE)
|
||||
handleNodeSelect(candidateNode.id)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -73,7 +81,16 @@ const CandidateNode = () => {
|
||||
transformOrigin: '0 0',
|
||||
}}
|
||||
>
|
||||
<CustomNode {...candidateNode as any} />
|
||||
{
|
||||
candidateNode.type === CUSTOM_NODE && (
|
||||
<CustomNode {...candidateNode as any} />
|
||||
)
|
||||
}
|
||||
{
|
||||
candidateNode.type === CUSTOM_NOTE_NODE && (
|
||||
<CustomNoteNode {...candidateNode as any} />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@@ -391,3 +391,4 @@ export const PARAMETER_EXTRACTOR_COMMON_STRUCT: Var[] = [
|
||||
]
|
||||
|
||||
export const WORKFLOW_DATA_UPDATE = 'WORKFLOW_DATA_UPDATE'
|
||||
export const CUSTOM_NODE = 'custom'
|
||||
|
@@ -14,7 +14,10 @@ import {
|
||||
getToolCheckParams,
|
||||
getValidTreeNodes,
|
||||
} from '../utils'
|
||||
import { MAX_TREE_DEEPTH } from '../constants'
|
||||
import {
|
||||
CUSTOM_NODE,
|
||||
MAX_TREE_DEEPTH,
|
||||
} from '../constants'
|
||||
import type { ToolNodeType } from '../nodes/tool/types'
|
||||
import { useIsChatMode } from './use-workflow'
|
||||
import { useNodesExtraData } from './use-nodes-data'
|
||||
@@ -33,7 +36,7 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
|
||||
|
||||
const needWarningNodes = useMemo(() => {
|
||||
const list = []
|
||||
const { validNodes } = getValidTreeNodes(nodes, edges)
|
||||
const { validNodes } = getValidTreeNodes(nodes.filter(node => node.type === CUSTOM_NODE), edges)
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i]
|
||||
@@ -53,17 +56,20 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
|
||||
if (provider_type === CollectionType.workflow)
|
||||
toolIcon = workflowTools.find(tool => tool.id === node.data.provider_id)?.icon
|
||||
}
|
||||
const { errorMessage } = nodesExtraData[node.data.type].checkValid(node.data, t, moreDataForCheckValid)
|
||||
|
||||
if (errorMessage || !validNodes.find(n => n.id === node.id)) {
|
||||
list.push({
|
||||
id: node.id,
|
||||
type: node.data.type,
|
||||
title: node.data.title,
|
||||
toolIcon,
|
||||
unConnected: !validNodes.find(n => n.id === node.id),
|
||||
errorMessage,
|
||||
})
|
||||
if (node.type === CUSTOM_NODE) {
|
||||
const { errorMessage } = nodesExtraData[node.data.type].checkValid(node.data, t, moreDataForCheckValid)
|
||||
|
||||
if (errorMessage || !validNodes.find(n => n.id === node.id)) {
|
||||
list.push({
|
||||
id: node.id,
|
||||
type: node.data.type,
|
||||
title: node.data.title,
|
||||
toolIcon,
|
||||
unConnected: !validNodes.find(n => n.id === node.id),
|
||||
errorMessage,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,11 +113,11 @@ export const useChecklistBeforePublish = () => {
|
||||
getNodes,
|
||||
edges,
|
||||
} = store.getState()
|
||||
const nodes = getNodes()
|
||||
const nodes = getNodes().filter(node => node.type === CUSTOM_NODE)
|
||||
const {
|
||||
validNodes,
|
||||
maxDepth,
|
||||
} = getValidTreeNodes(nodes, edges)
|
||||
} = getValidTreeNodes(nodes.filter(node => node.type === CUSTOM_NODE), edges)
|
||||
|
||||
if (maxDepth > MAX_TREE_DEEPTH) {
|
||||
notify({ type: 'error', message: t('workflow.common.maxTreeDepth', { depth: MAX_TREE_DEEPTH }) })
|
||||
|
@@ -22,7 +22,8 @@ export const useNodeDataUpdate = () => {
|
||||
const newNodes = produce(getNodes(), (draft) => {
|
||||
const currentNode = draft.find(node => node.id === id)!
|
||||
|
||||
currentNode.data = { ...currentNode?.data, ...data }
|
||||
if (currentNode)
|
||||
currentNode.data = { ...currentNode.data, ...data }
|
||||
})
|
||||
setNodes(newNodes)
|
||||
}, [store])
|
||||
|
@@ -38,6 +38,7 @@ import {
|
||||
getNodesConnectedSourceOrTargetHandleIdsMap,
|
||||
getTopLeftNodePosition,
|
||||
} from '../utils'
|
||||
import { CUSTOM_NOTE_NODE } from '../note-node/constants'
|
||||
import type { IterationNodeType } from '../nodes/iteration/types'
|
||||
import type { VariableAssignerNodeType } from '../nodes/variable-assigner/types'
|
||||
import { useNodeIterationInteractions } from '../nodes/iteration/use-interactions'
|
||||
@@ -71,7 +72,7 @@ export const useNodesInteractions = () => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
if (node.data.isIterationStart)
|
||||
if (node.data.isIterationStart || node.type === CUSTOM_NOTE_NODE)
|
||||
return
|
||||
|
||||
dragNodeStartPosition.current = { x: node.position.x, y: node.position.y }
|
||||
@@ -143,6 +144,9 @@ export const useNodesInteractions = () => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
if (node.type === CUSTOM_NOTE_NODE)
|
||||
return
|
||||
|
||||
const {
|
||||
getNodes,
|
||||
setNodes,
|
||||
@@ -193,10 +197,13 @@ export const useNodesInteractions = () => {
|
||||
setEdges(newEdges)
|
||||
}, [store, workflowStore, getNodesReadOnly])
|
||||
|
||||
const handleNodeLeave = useCallback<NodeMouseHandler>(() => {
|
||||
const handleNodeLeave = useCallback<NodeMouseHandler>((_, node) => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
if (node.type === CUSTOM_NOTE_NODE)
|
||||
return
|
||||
|
||||
const {
|
||||
setEnteringNodePayload,
|
||||
} = workflowStore.getState()
|
||||
@@ -298,6 +305,9 @@ export const useNodesInteractions = () => {
|
||||
if (targetNode?.data.isIterationStart)
|
||||
return
|
||||
|
||||
if (sourceNode?.type === CUSTOM_NOTE_NODE || targetNode?.type === CUSTOM_NOTE_NODE)
|
||||
return
|
||||
|
||||
const needDeleteEdges = edges.filter((edge) => {
|
||||
if (
|
||||
(edge.source === source && edge.sourceHandle === sourceHandle)
|
||||
@@ -361,6 +371,9 @@ export const useNodesInteractions = () => {
|
||||
const { getNodes } = store.getState()
|
||||
const node = getNodes().find(n => n.id === nodeId)!
|
||||
|
||||
if (node.type === CUSTOM_NOTE_NODE)
|
||||
return
|
||||
|
||||
if (node.data.type === BlockEnum.VariableAggregator || node.data.type === BlockEnum.VariableAssigner) {
|
||||
if (handleType === 'target')
|
||||
return
|
||||
@@ -975,6 +988,9 @@ export const useNodesInteractions = () => {
|
||||
}, [store])
|
||||
|
||||
const handleNodeContextMenu = useCallback((e: MouseEvent, node: Node) => {
|
||||
if (node.type === CUSTOM_NOTE_NODE)
|
||||
return
|
||||
|
||||
e.preventDefault()
|
||||
const container = document.querySelector('#workflow-container')
|
||||
const { x, y } = container!.getBoundingClientRect()
|
||||
@@ -1051,6 +1067,7 @@ export const useNodesInteractions = () => {
|
||||
const nodeType = nodeToPaste.data.type
|
||||
|
||||
const newNode = generateNewNode({
|
||||
type: nodeToPaste.type,
|
||||
data: {
|
||||
...NODES_INITIAL_DATA[nodeType],
|
||||
...nodeToPaste.data,
|
||||
|
@@ -34,8 +34,10 @@ import {
|
||||
useWorkflowStore,
|
||||
} from '../store'
|
||||
import {
|
||||
CUSTOM_NODE,
|
||||
SUPPORT_OUTPUT_VARS_NODE,
|
||||
} from '../constants'
|
||||
import { CUSTOM_NOTE_NODE } from '../note-node/constants'
|
||||
import { findUsedVarNodes, getNodeOutputVars, updateNodeVars } from '../nodes/_base/components/variable/utils'
|
||||
import { useNodesExtraData } from './use-nodes-data'
|
||||
import { useWorkflowTemplate } from './use-workflow-template'
|
||||
@@ -88,7 +90,7 @@ export const useWorkflow = () => {
|
||||
const rankMap = {} as Record<string, Node>
|
||||
|
||||
nodes.forEach((node) => {
|
||||
if (!node.parentId) {
|
||||
if (!node.parentId && node.type === CUSTOM_NODE) {
|
||||
const rank = layout.node(node.id).rank!
|
||||
|
||||
if (!rankMap[rank]) {
|
||||
@@ -103,7 +105,7 @@ export const useWorkflow = () => {
|
||||
|
||||
const newNodes = produce(nodes, (draft) => {
|
||||
draft.forEach((node) => {
|
||||
if (!node.parentId) {
|
||||
if (!node.parentId && node.type === CUSTOM_NODE) {
|
||||
const nodeWithPosition = layout.node(node.id)
|
||||
|
||||
node.position = {
|
||||
@@ -345,6 +347,9 @@ export const useWorkflow = () => {
|
||||
if (targetNode.data.isIterationStart)
|
||||
return false
|
||||
|
||||
if (sourceNode.type === CUSTOM_NOTE_NODE || targetNode.type === CUSTOM_NOTE_NODE)
|
||||
return false
|
||||
|
||||
if (sourceNode && targetNode) {
|
||||
const sourceNodeAvailableNextNodes = nodesExtraData[sourceNode.data.type].availableNextNodes
|
||||
const targetNodeAvailablePrevNodes = [...nodesExtraData[targetNode.data.type].availablePrevNodes, BlockEnum.Start]
|
||||
|
@@ -46,6 +46,8 @@ import {
|
||||
} from './hooks'
|
||||
import Header from './header'
|
||||
import CustomNode from './nodes'
|
||||
import CustomNoteNode from './note-node'
|
||||
import { CUSTOM_NOTE_NODE } from './note-node/constants'
|
||||
import Operator from './operator'
|
||||
import CustomEdge from './custom-edge'
|
||||
import CustomConnectionLine from './custom-connection-line'
|
||||
@@ -66,6 +68,7 @@ import {
|
||||
initialNodes,
|
||||
} from './utils'
|
||||
import {
|
||||
CUSTOM_NODE,
|
||||
ITERATION_CHILDREN_Z_INDEX,
|
||||
WORKFLOW_DATA_UPDATE,
|
||||
} from './constants'
|
||||
@@ -76,10 +79,11 @@ import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import Confirm from '@/app/components/base/confirm/common'
|
||||
|
||||
const nodeTypes = {
|
||||
custom: CustomNode,
|
||||
[CUSTOM_NODE]: CustomNode,
|
||||
[CUSTOM_NOTE_NODE]: CustomNoteNode,
|
||||
}
|
||||
const edgeTypes = {
|
||||
custom: CustomEdge,
|
||||
[CUSTOM_NODE]: CustomEdge,
|
||||
}
|
||||
|
||||
type WorkflowProps = {
|
||||
|
@@ -19,10 +19,18 @@ const Icon = () => {
|
||||
type NodeResizerProps = {
|
||||
nodeId: string
|
||||
nodeData: CommonNodeType
|
||||
icon?: JSX.Element
|
||||
minWidth?: number
|
||||
minHeight?: number
|
||||
maxWidth?: number
|
||||
}
|
||||
const NodeResizer = ({
|
||||
nodeId,
|
||||
nodeData,
|
||||
icon = <Icon />,
|
||||
minWidth = 272,
|
||||
minHeight = 176,
|
||||
maxWidth,
|
||||
}: NodeResizerProps) => {
|
||||
const { handleNodeResize } = useNodesInteractions()
|
||||
|
||||
@@ -39,10 +47,11 @@ const NodeResizer = ({
|
||||
position='bottom-right'
|
||||
className='!border-none !bg-transparent'
|
||||
onResize={handleResize}
|
||||
minWidth={272}
|
||||
minHeight={176}
|
||||
minWidth={minWidth}
|
||||
minHeight={minHeight}
|
||||
maxWidth={maxWidth}
|
||||
>
|
||||
<div className='absolute bottom-[1px] right-[1px]'><Icon /></div>
|
||||
<div className='absolute bottom-[1px] right-[1px]'>{icon}</div>
|
||||
</NodeResizeControl>
|
||||
</div>
|
||||
)
|
||||
|
@@ -64,3 +64,5 @@ export const PanelComponentMap: Record<string, ComponentType<any>> = {
|
||||
[BlockEnum.ParameterExtractor]: ParameterExtractorPanel,
|
||||
[BlockEnum.Iteration]: IterationPanel,
|
||||
}
|
||||
|
||||
export const CUSTOM_NODE_TYPE = 'custom'
|
||||
|
@@ -1,6 +1,10 @@
|
||||
import { memo } from 'react'
|
||||
import {
|
||||
memo,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import type { NodeProps } from 'reactflow'
|
||||
import type { Node } from '../types'
|
||||
import { CUSTOM_NODE } from '../constants'
|
||||
import {
|
||||
NodeComponentMap,
|
||||
PanelComponentMap,
|
||||
@@ -23,14 +27,24 @@ const CustomNode = (props: NodeProps) => {
|
||||
CustomNode.displayName = 'CustomNode'
|
||||
|
||||
export const Panel = memo((props: Node) => {
|
||||
const nodeClass = props.type
|
||||
const nodeData = props.data
|
||||
const PanelComponent = PanelComponentMap[nodeData.type]
|
||||
const PanelComponent = useMemo(() => {
|
||||
if (nodeClass === CUSTOM_NODE)
|
||||
return PanelComponentMap[nodeData.type]
|
||||
|
||||
return (
|
||||
<BasePanel key={props.id} {...props}>
|
||||
<PanelComponent />
|
||||
</BasePanel>
|
||||
)
|
||||
return () => null
|
||||
}, [nodeClass, nodeData.type])
|
||||
|
||||
if (nodeClass === CUSTOM_NODE) {
|
||||
return (
|
||||
<BasePanel key={props.id} {...props}>
|
||||
<PanelComponent />
|
||||
</BasePanel>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
Panel.displayName = 'Panel'
|
||||
|
42
web/app/components/workflow/note-node/constants.ts
Normal file
42
web/app/components/workflow/note-node/constants.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NoteTheme } from './types'
|
||||
|
||||
export const CUSTOM_NOTE_NODE = 'custom-note'
|
||||
|
||||
export const THEME_MAP: Record<string, { outer: string; title: string; bg: string; border: string }> = {
|
||||
[NoteTheme.blue]: {
|
||||
outer: '#2E90FA',
|
||||
title: '#D1E9FF',
|
||||
bg: '#EFF8FF',
|
||||
border: '#84CAFF',
|
||||
},
|
||||
[NoteTheme.cyan]: {
|
||||
outer: '#06AED4',
|
||||
title: '#CFF9FE',
|
||||
bg: '#ECFDFF',
|
||||
border: '#67E3F9',
|
||||
},
|
||||
[NoteTheme.green]: {
|
||||
outer: '#16B364',
|
||||
title: '#D3F8DF',
|
||||
bg: '#EDFCF2',
|
||||
border: '#73E2A3',
|
||||
},
|
||||
[NoteTheme.yellow]: {
|
||||
outer: '#EAAA08',
|
||||
title: '#FEF7C3',
|
||||
bg: '#FEFBE8',
|
||||
border: '#FDE272',
|
||||
},
|
||||
[NoteTheme.pink]: {
|
||||
outer: '#EE46BC',
|
||||
title: '#FCE7F6',
|
||||
bg: '#FDF2FA',
|
||||
border: '#FAA7E0',
|
||||
},
|
||||
[NoteTheme.violet]: {
|
||||
outer: '#875BF7',
|
||||
title: '#ECE9FE',
|
||||
bg: '#F5F3FF',
|
||||
border: '#C3B5FD',
|
||||
},
|
||||
}
|
29
web/app/components/workflow/note-node/hooks.ts
Normal file
29
web/app/components/workflow/note-node/hooks.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useCallback } from 'react'
|
||||
import type { EditorState } from 'lexical'
|
||||
import { useNodeDataUpdate } from '../hooks'
|
||||
import type { NoteTheme } from './types'
|
||||
|
||||
export const useNote = (id: string) => {
|
||||
const { handleNodeDataUpdateWithSyncDraft } = useNodeDataUpdate()
|
||||
|
||||
const handleThemeChange = useCallback((theme: NoteTheme) => {
|
||||
handleNodeDataUpdateWithSyncDraft({ id, data: { theme } })
|
||||
}, [handleNodeDataUpdateWithSyncDraft, id])
|
||||
|
||||
const handleEditorChange = useCallback((editorState: EditorState) => {
|
||||
if (!editorState?.isEmpty())
|
||||
handleNodeDataUpdateWithSyncDraft({ id, data: { text: JSON.stringify(editorState) } })
|
||||
else
|
||||
handleNodeDataUpdateWithSyncDraft({ id, data: { text: '' } })
|
||||
}, [handleNodeDataUpdateWithSyncDraft, id])
|
||||
|
||||
const handleShowAuthorChange = useCallback((showAuthor: boolean) => {
|
||||
handleNodeDataUpdateWithSyncDraft({ id, data: { showAuthor } })
|
||||
}, [handleNodeDataUpdateWithSyncDraft, id])
|
||||
|
||||
return {
|
||||
handleThemeChange,
|
||||
handleEditorChange,
|
||||
handleShowAuthorChange,
|
||||
}
|
||||
}
|
127
web/app/components/workflow/note-node/index.tsx
Normal file
127
web/app/components/workflow/note-node/index.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import cn from 'classnames'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useClickAway } from 'ahooks'
|
||||
import type { NodeProps } from 'reactflow'
|
||||
import NodeResizer from '../nodes/_base/components/node-resizer'
|
||||
import {
|
||||
useNodeDataUpdate,
|
||||
useNodesInteractions,
|
||||
} from '../hooks'
|
||||
import { useStore } from '../store'
|
||||
import {
|
||||
NoteEditor,
|
||||
NoteEditorContextProvider,
|
||||
NoteEditorToolbar,
|
||||
} from './note-editor'
|
||||
import { THEME_MAP } from './constants'
|
||||
import { useNote } from './hooks'
|
||||
import type { NoteNodeType } from './types'
|
||||
|
||||
const Icon = () => {
|
||||
return (
|
||||
<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"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const NoteNode = ({
|
||||
id,
|
||||
data,
|
||||
}: NodeProps<NoteNodeType>) => {
|
||||
const { t } = useTranslation()
|
||||
const controlPromptEditorRerenderKey = useStore(s => s.controlPromptEditorRerenderKey)
|
||||
const ref = useRef<HTMLDivElement | null>(null)
|
||||
const theme = data.theme
|
||||
const {
|
||||
handleThemeChange,
|
||||
handleEditorChange,
|
||||
handleShowAuthorChange,
|
||||
} = useNote(id)
|
||||
const {
|
||||
handleNodesCopy,
|
||||
handleNodesDuplicate,
|
||||
handleNodeDelete,
|
||||
} = useNodesInteractions()
|
||||
const { handleNodeDataUpdateWithSyncDraft } = useNodeDataUpdate()
|
||||
|
||||
const handleDeleteNode = useCallback(() => {
|
||||
handleNodeDelete(id)
|
||||
}, [id, handleNodeDelete])
|
||||
|
||||
useClickAway(() => {
|
||||
handleNodeDataUpdateWithSyncDraft({ id, data: { selected: false } })
|
||||
}, ref)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col relative rounded-md shadow-xs border hover:shadow-md',
|
||||
)}
|
||||
style={{
|
||||
background: THEME_MAP[theme].bg,
|
||||
borderColor: data.selected ? THEME_MAP[theme].border : 'rgba(0, 0, 0, 0.05)',
|
||||
width: data.width,
|
||||
height: data.height,
|
||||
}}
|
||||
ref={ref}
|
||||
>
|
||||
<NoteEditorContextProvider
|
||||
key={controlPromptEditorRerenderKey}
|
||||
value={data.text}
|
||||
>
|
||||
<>
|
||||
<NodeResizer
|
||||
nodeId={id}
|
||||
nodeData={data}
|
||||
icon={<Icon />}
|
||||
minWidth={240}
|
||||
maxWidth={640}
|
||||
minHeight={88}
|
||||
/>
|
||||
<div className='shrink-0 h-2 opacity-50 rounded-t-md' style={{ background: THEME_MAP[theme].title }}></div>
|
||||
{
|
||||
data.selected && (
|
||||
<div className='absolute -top-[41px] left-1/2 -translate-x-1/2'>
|
||||
<NoteEditorToolbar
|
||||
theme={theme}
|
||||
onThemeChange={handleThemeChange}
|
||||
onCopy={handleNodesCopy}
|
||||
onDuplicate={handleNodesDuplicate}
|
||||
onDelete={handleDeleteNode}
|
||||
showAuthor={data.showAuthor}
|
||||
onShowAuthorChange={handleShowAuthorChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div className='grow px-3 py-2.5 overflow-y-auto'>
|
||||
<div className={cn(
|
||||
data.selected && 'nodrag nopan nowheel cursor-text',
|
||||
)}>
|
||||
<NoteEditor
|
||||
containerElement={ref.current}
|
||||
placeholder={t('workflow.nodes.note.editor.placeholder') || ''}
|
||||
onChange={handleEditorChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
data.showAuthor && (
|
||||
<div className='p-3 pt-0 text-xs text-black/[0.32]'>
|
||||
{data.author}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</>
|
||||
</NoteEditorContextProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(NoteNode)
|
@@ -0,0 +1,65 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import { LexicalComposer } from '@lexical/react/LexicalComposer'
|
||||
import { LinkNode } from '@lexical/link'
|
||||
import {
|
||||
ListItemNode,
|
||||
ListNode,
|
||||
} from '@lexical/list'
|
||||
import { createNoteEditorStore } from './store'
|
||||
import theme from './theme'
|
||||
|
||||
type NoteEditorStore = ReturnType<typeof createNoteEditorStore>
|
||||
const NoteEditorContext = createContext<NoteEditorStore | null>(null)
|
||||
|
||||
type NoteEditorContextProviderProps = {
|
||||
value: string
|
||||
children: JSX.Element | string | (JSX.Element | string)[]
|
||||
}
|
||||
export const NoteEditorContextProvider = memo(({
|
||||
value,
|
||||
children,
|
||||
}: NoteEditorContextProviderProps) => {
|
||||
const storeRef = useRef<NoteEditorStore>()
|
||||
|
||||
if (!storeRef.current)
|
||||
storeRef.current = createNoteEditorStore()
|
||||
|
||||
let initialValue = null
|
||||
try {
|
||||
initialValue = JSON.parse(value)
|
||||
}
|
||||
catch (e) {
|
||||
|
||||
}
|
||||
|
||||
const initialConfig = {
|
||||
namespace: 'note-editor',
|
||||
nodes: [
|
||||
LinkNode,
|
||||
ListNode,
|
||||
ListItemNode,
|
||||
],
|
||||
editorState: !initialValue?.root.children.length ? null : JSON.stringify(initialValue),
|
||||
onError: (error: Error) => {
|
||||
throw error
|
||||
},
|
||||
theme,
|
||||
}
|
||||
|
||||
return (
|
||||
<NoteEditorContext.Provider value={storeRef.current}>
|
||||
<LexicalComposer initialConfig={{ ...initialConfig }}>
|
||||
{children}
|
||||
</LexicalComposer>
|
||||
</NoteEditorContext.Provider>
|
||||
)
|
||||
})
|
||||
NoteEditorContextProvider.displayName = 'NoteEditorContextProvider'
|
||||
|
||||
export default NoteEditorContext
|
62
web/app/components/workflow/note-node/note-editor/editor.tsx
Normal file
62
web/app/components/workflow/note-node/note-editor/editor.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
} from 'react'
|
||||
import type { EditorState } from 'lexical'
|
||||
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
|
||||
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
|
||||
import { ClickableLinkPlugin } from '@lexical/react/LexicalClickableLinkPlugin'
|
||||
import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin'
|
||||
import { ListPlugin } from '@lexical/react/LexicalListPlugin'
|
||||
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
|
||||
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'
|
||||
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'
|
||||
import LinkEditorPlugin from './plugins/link-editor-plugin'
|
||||
import FormatDetectorPlugin from './plugins/format-detector-plugin'
|
||||
// import TreeView from '@/app/components/base/prompt-editor/plugins/tree-view'
|
||||
import Placeholder from '@/app/components/base/prompt-editor/plugins/placeholder'
|
||||
|
||||
type EditorProps = {
|
||||
placeholder?: string
|
||||
onChange?: (editorState: EditorState) => void
|
||||
containerElement: HTMLDivElement | null
|
||||
}
|
||||
const Editor = ({
|
||||
placeholder = 'write you note...',
|
||||
onChange,
|
||||
containerElement,
|
||||
}: EditorProps) => {
|
||||
const handleEditorChange = useCallback((editorState: EditorState) => {
|
||||
onChange?.(editorState)
|
||||
}, [onChange])
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
<RichTextPlugin
|
||||
contentEditable={
|
||||
<div>
|
||||
<ContentEditable
|
||||
spellCheck={false}
|
||||
className='w-full h-full outline-none caret-primary-600'
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
placeholder={<Placeholder value={placeholder} compact />}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
<ClickableLinkPlugin disabled />
|
||||
<LinkPlugin />
|
||||
<ListPlugin />
|
||||
<LinkEditorPlugin containerElement={containerElement} />
|
||||
<FormatDetectorPlugin />
|
||||
<HistoryPlugin />
|
||||
<OnChangePlugin onChange={handleEditorChange} />
|
||||
{/* <TreeView /> */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Editor)
|
@@ -0,0 +1,3 @@
|
||||
export { NoteEditorContextProvider } from './context'
|
||||
export { default as NoteEditor } from './editor'
|
||||
export { default as NoteEditorToolbar } from './toolbar'
|
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
} from 'react'
|
||||
import {
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
} from 'lexical'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import type { LinkNode } from '@lexical/link'
|
||||
import { $isLinkNode } from '@lexical/link'
|
||||
import { $isListItemNode } from '@lexical/list'
|
||||
import { getSelectedNode } from '../../utils'
|
||||
import { useNoteEditorStore } from '../../store'
|
||||
|
||||
export const useFormatDetector = () => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const noteEditorStore = useNoteEditorStore()
|
||||
|
||||
const handleFormat = useCallback(() => {
|
||||
editor.getEditorState().read(() => {
|
||||
if (editor.isComposing())
|
||||
return
|
||||
|
||||
const selection = $getSelection()
|
||||
|
||||
if ($isRangeSelection(selection)) {
|
||||
const node = getSelectedNode(selection)
|
||||
const {
|
||||
setSelectedIsBold,
|
||||
setSelectedIsItalic,
|
||||
setSelectedIsStrikeThrough,
|
||||
setSelectedLinkUrl,
|
||||
setSelectedIsLink,
|
||||
setSelectedIsBullet,
|
||||
} = noteEditorStore.getState()
|
||||
setSelectedIsBold(selection.hasFormat('bold'))
|
||||
setSelectedIsItalic(selection.hasFormat('italic'))
|
||||
setSelectedIsStrikeThrough(selection.hasFormat('strikethrough'))
|
||||
const parent = node.getParent()
|
||||
if ($isLinkNode(parent) || $isLinkNode(node)) {
|
||||
const linkUrl = ($isLinkNode(parent) ? parent : node as LinkNode).getURL()
|
||||
setSelectedLinkUrl(linkUrl)
|
||||
setSelectedIsLink(true)
|
||||
}
|
||||
else {
|
||||
setSelectedLinkUrl('')
|
||||
setSelectedIsLink(false)
|
||||
}
|
||||
|
||||
if ($isListItemNode(parent) || $isListItemNode(node))
|
||||
setSelectedIsBullet(true)
|
||||
else
|
||||
setSelectedIsBullet(false)
|
||||
}
|
||||
})
|
||||
}, [editor, noteEditorStore])
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('selectionchange', handleFormat)
|
||||
return () => {
|
||||
document.removeEventListener('selectionchange', handleFormat)
|
||||
}
|
||||
}, [handleFormat])
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
editor.registerUpdateListener(() => {
|
||||
handleFormat()
|
||||
}),
|
||||
)
|
||||
}, [editor, handleFormat])
|
||||
|
||||
return {
|
||||
handleFormat,
|
||||
}
|
||||
}
|
@@ -0,0 +1,9 @@
|
||||
import { useFormatDetector } from './hooks'
|
||||
|
||||
const FormatDetectorPlugin = () => {
|
||||
useFormatDetector()
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default FormatDetectorPlugin
|
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
memo,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { escape } from 'lodash-es'
|
||||
import {
|
||||
FloatingPortal,
|
||||
flip,
|
||||
offset,
|
||||
shift,
|
||||
useFloating,
|
||||
} from '@floating-ui/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useClickAway } from 'ahooks'
|
||||
import cn from 'classnames'
|
||||
import { useStore } from '../../store'
|
||||
import { useLink } from './hooks'
|
||||
import Button from '@/app/components/base/button'
|
||||
import {
|
||||
Edit03,
|
||||
LinkBroken01,
|
||||
LinkExternal01,
|
||||
} from '@/app/components/base/icons/src/vender/line/general'
|
||||
|
||||
type LinkEditorComponentProps = {
|
||||
containerElement: HTMLDivElement | null
|
||||
}
|
||||
const LinkEditorComponent = ({
|
||||
containerElement,
|
||||
}: LinkEditorComponentProps) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
handleSaveLink,
|
||||
handleUnlink,
|
||||
} = useLink()
|
||||
const selectedLinkUrl = useStore(s => s.selectedLinkUrl)
|
||||
const linkAnchorElement = useStore(s => s.linkAnchorElement)
|
||||
const linkOperatorShow = useStore(s => s.linkOperatorShow)
|
||||
const setLinkAnchorElement = useStore(s => s.setLinkAnchorElement)
|
||||
const setLinkOperatorShow = useStore(s => s.setLinkOperatorShow)
|
||||
const [url, setUrl] = useState(selectedLinkUrl)
|
||||
const { refs, floatingStyles, elements } = useFloating({
|
||||
placement: 'top',
|
||||
middleware: [
|
||||
offset(4),
|
||||
shift(),
|
||||
flip(),
|
||||
],
|
||||
})
|
||||
|
||||
useClickAway(() => {
|
||||
setLinkAnchorElement()
|
||||
}, linkAnchorElement)
|
||||
|
||||
useEffect(() => {
|
||||
setUrl(selectedLinkUrl)
|
||||
}, [selectedLinkUrl])
|
||||
|
||||
useEffect(() => {
|
||||
if (linkAnchorElement)
|
||||
refs.setReference(linkAnchorElement)
|
||||
}, [linkAnchorElement, refs])
|
||||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
elements.reference && (
|
||||
<FloatingPortal root={containerElement}>
|
||||
<div
|
||||
className={cn(
|
||||
'nodrag nopan inline-flex items-center w-max rounded-md border-[0.5px] border-black/5 bg-white z-10',
|
||||
!linkOperatorShow && 'p-1 shadow-md',
|
||||
linkOperatorShow && 'p-0.5 shadow-sm text-xs text-gray-500 font-medium',
|
||||
)}
|
||||
style={floatingStyles}
|
||||
ref={refs.setFloating}
|
||||
>
|
||||
{
|
||||
!linkOperatorShow && (
|
||||
<>
|
||||
<input
|
||||
className='mr-0.5 p-1 w-[196px] h-6 rounded-sm text-[13px] appearance-none outline-none'
|
||||
value={url}
|
||||
onChange={e => setUrl(e.target.value)}
|
||||
placeholder={t('workflow.nodes.note.editor.enterUrl') || ''}
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
type='primary'
|
||||
className={cn(
|
||||
'py-0 px-2 h-6 text-xs',
|
||||
!url && 'cursor-not-allowed',
|
||||
)}
|
||||
disabled={!url}
|
||||
onClick={() => handleSaveLink(url)}
|
||||
>
|
||||
{t('common.operation.ok')}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
linkOperatorShow && (
|
||||
<>
|
||||
<a
|
||||
className='flex items-center px-2 h-6 rounded-md hover:bg-gray-50'
|
||||
href={escape(url)}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
<LinkExternal01 className='mr-1 w-3 h-3' />
|
||||
<div className='mr-1'>
|
||||
{t('workflow.nodes.note.editor.openLink')}
|
||||
</div>
|
||||
<div
|
||||
title={escape(url)}
|
||||
className='text-primary-600 max-w-[140px] truncate'
|
||||
>
|
||||
{escape(url)}
|
||||
</div>
|
||||
</a>
|
||||
<div className='mx-1 w-[1px] h-3.5 bg-gray-100'></div>
|
||||
<div
|
||||
className='flex items-center mr-0.5 px-2 h-6 rounded-md cursor-pointer hover:bg-gray-50'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setLinkOperatorShow(false)
|
||||
}}
|
||||
>
|
||||
<Edit03 className='mr-1 w-3 h-3' />
|
||||
{t('common.operation.edit')}
|
||||
</div>
|
||||
<div
|
||||
className='flex items-center px-2 h-6 rounded-md cursor-pointer hover:bg-gray-50'
|
||||
onClick={handleUnlink}
|
||||
>
|
||||
<LinkBroken01 className='mr-1 w-3 h-3' />
|
||||
{t('workflow.nodes.note.editor.unlink')}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</FloatingPortal>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(LinkEditorComponent)
|
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
CLICK_COMMAND,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
} from 'lexical'
|
||||
import {
|
||||
mergeRegister,
|
||||
} from '@lexical/utils'
|
||||
import {
|
||||
TOGGLE_LINK_COMMAND,
|
||||
} from '@lexical/link'
|
||||
import { escape } from 'lodash-es'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { useNoteEditorStore } from '../../store'
|
||||
import { urlRegExp } from '../../utils'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
|
||||
export const useOpenLink = () => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const noteEditorStore = useNoteEditorStore()
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
editor.registerUpdateListener(() => {
|
||||
setTimeout(() => {
|
||||
const {
|
||||
selectedLinkUrl,
|
||||
selectedIsLink,
|
||||
setLinkAnchorElement,
|
||||
setLinkOperatorShow,
|
||||
} = noteEditorStore.getState()
|
||||
|
||||
if (selectedIsLink) {
|
||||
setLinkAnchorElement(true)
|
||||
|
||||
if (selectedLinkUrl)
|
||||
setLinkOperatorShow(true)
|
||||
else
|
||||
setLinkOperatorShow(false)
|
||||
}
|
||||
else {
|
||||
setLinkAnchorElement()
|
||||
setLinkOperatorShow(false)
|
||||
}
|
||||
})
|
||||
}),
|
||||
editor.registerCommand(
|
||||
CLICK_COMMAND,
|
||||
(payload) => {
|
||||
setTimeout(() => {
|
||||
const {
|
||||
selectedLinkUrl,
|
||||
selectedIsLink,
|
||||
setLinkAnchorElement,
|
||||
setLinkOperatorShow,
|
||||
} = noteEditorStore.getState()
|
||||
|
||||
if (selectedIsLink) {
|
||||
if ((payload.metaKey || payload.ctrlKey) && selectedLinkUrl) {
|
||||
window.open(selectedLinkUrl, '_blank')
|
||||
return true
|
||||
}
|
||||
setLinkAnchorElement(true)
|
||||
|
||||
if (selectedLinkUrl)
|
||||
setLinkOperatorShow(true)
|
||||
else
|
||||
setLinkOperatorShow(false)
|
||||
}
|
||||
else {
|
||||
setLinkAnchorElement()
|
||||
setLinkOperatorShow(false)
|
||||
}
|
||||
})
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
)
|
||||
}, [editor, noteEditorStore])
|
||||
}
|
||||
|
||||
export const useLink = () => {
|
||||
const { t } = useTranslation()
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const noteEditorStore = useNoteEditorStore()
|
||||
const { notify } = useToastContext()
|
||||
|
||||
const handleSaveLink = useCallback((url: string) => {
|
||||
if (url && !urlRegExp.test(url)) {
|
||||
notify({ type: 'error', message: t('workflow.nodes.note.editor.invalidUrl') })
|
||||
return
|
||||
}
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, escape(url))
|
||||
|
||||
const { setLinkAnchorElement } = noteEditorStore.getState()
|
||||
setLinkAnchorElement()
|
||||
}, [editor, noteEditorStore, notify, t])
|
||||
|
||||
const handleUnlink = useCallback(() => {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
|
||||
|
||||
const { setLinkAnchorElement } = noteEditorStore.getState()
|
||||
setLinkAnchorElement()
|
||||
}, [editor, noteEditorStore])
|
||||
|
||||
return {
|
||||
handleSaveLink,
|
||||
handleUnlink,
|
||||
}
|
||||
}
|
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
memo,
|
||||
} from 'react'
|
||||
import { useStore } from '../../store'
|
||||
import { useOpenLink } from './hooks'
|
||||
import LinkEditorComponent from './component'
|
||||
|
||||
type LinkEditorPluginProps = {
|
||||
containerElement: HTMLDivElement | null
|
||||
}
|
||||
const LinkEditorPlugin = ({
|
||||
containerElement,
|
||||
}: LinkEditorPluginProps) => {
|
||||
useOpenLink()
|
||||
const linkAnchorElement = useStore(s => s.linkAnchorElement)
|
||||
|
||||
if (!linkAnchorElement)
|
||||
return null
|
||||
|
||||
return (
|
||||
<LinkEditorComponent containerElement={containerElement} />
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(LinkEditorPlugin)
|
72
web/app/components/workflow/note-node/note-editor/store.ts
Normal file
72
web/app/components/workflow/note-node/note-editor/store.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useContext } from 'react'
|
||||
import {
|
||||
useStore as useZustandStore,
|
||||
} from 'zustand'
|
||||
import { createStore } from 'zustand/vanilla'
|
||||
import NoteEditorContext from './context'
|
||||
|
||||
type Shape = {
|
||||
linkAnchorElement: HTMLElement | null
|
||||
setLinkAnchorElement: (open?: boolean) => void
|
||||
linkOperatorShow: boolean
|
||||
setLinkOperatorShow: (linkOperatorShow: boolean) => void
|
||||
selectedIsBold: boolean
|
||||
setSelectedIsBold: (selectedIsBold: boolean) => void
|
||||
selectedIsItalic: boolean
|
||||
setSelectedIsItalic: (selectedIsItalic: boolean) => void
|
||||
selectedIsStrikeThrough: boolean
|
||||
setSelectedIsStrikeThrough: (selectedIsStrikeThrough: boolean) => void
|
||||
selectedLinkUrl: string
|
||||
setSelectedLinkUrl: (selectedLinkUrl: string) => void
|
||||
selectedIsLink: boolean
|
||||
setSelectedIsLink: (selectedIsLink: boolean) => void
|
||||
selectedIsBullet: boolean
|
||||
setSelectedIsBullet: (selectedIsBullet: boolean) => void
|
||||
}
|
||||
|
||||
export const createNoteEditorStore = () => {
|
||||
return createStore<Shape>(set => ({
|
||||
linkAnchorElement: null,
|
||||
setLinkAnchorElement: (open) => {
|
||||
if (open) {
|
||||
setTimeout(() => {
|
||||
const nativeSelection = window.getSelection()
|
||||
|
||||
if (nativeSelection?.focusNode) {
|
||||
const parent = nativeSelection.focusNode.parentElement
|
||||
set(() => ({ linkAnchorElement: parent }))
|
||||
}
|
||||
})
|
||||
}
|
||||
else {
|
||||
set(() => ({ linkAnchorElement: null }))
|
||||
}
|
||||
},
|
||||
linkOperatorShow: false,
|
||||
setLinkOperatorShow: linkOperatorShow => set(() => ({ linkOperatorShow })),
|
||||
selectedIsBold: false,
|
||||
setSelectedIsBold: selectedIsBold => set(() => ({ selectedIsBold })),
|
||||
selectedIsItalic: false,
|
||||
setSelectedIsItalic: selectedIsItalic => set(() => ({ selectedIsItalic })),
|
||||
selectedIsStrikeThrough: false,
|
||||
setSelectedIsStrikeThrough: selectedIsStrikeThrough => set(() => ({ selectedIsStrikeThrough })),
|
||||
selectedLinkUrl: '',
|
||||
setSelectedLinkUrl: selectedLinkUrl => set(() => ({ selectedLinkUrl })),
|
||||
selectedIsLink: false,
|
||||
setSelectedIsLink: selectedIsLink => set(() => ({ selectedIsLink })),
|
||||
selectedIsBullet: false,
|
||||
setSelectedIsBullet: selectedIsBullet => set(() => ({ selectedIsBullet })),
|
||||
}))
|
||||
}
|
||||
|
||||
export function useStore<T>(selector: (state: Shape) => T): T {
|
||||
const store = useContext(NoteEditorContext)
|
||||
if (!store)
|
||||
throw new Error('Missing NoteEditorContext.Provider in the tree')
|
||||
|
||||
return useZustandStore(store, selector)
|
||||
}
|
||||
|
||||
export const useNoteEditorStore = () => {
|
||||
return useContext(NoteEditorContext)!
|
||||
}
|
@@ -0,0 +1,17 @@
|
||||
import type { EditorThemeClasses } from 'lexical'
|
||||
|
||||
import './theme.css'
|
||||
|
||||
const theme: EditorThemeClasses = {
|
||||
paragraph: 'note-editor-theme_paragraph',
|
||||
list: {
|
||||
ul: 'note-editor-theme_list-ul',
|
||||
listitem: 'note-editor-theme_list-li',
|
||||
},
|
||||
link: 'note-editor-theme_link',
|
||||
text: {
|
||||
strikethrough: 'note-editor-theme_text-strikethrough',
|
||||
},
|
||||
}
|
||||
|
||||
export default theme
|
@@ -0,0 +1,24 @@
|
||||
.note-editor-theme_paragraph {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.note-editor-theme_list-ul {
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
.note-editor-theme_list-li {
|
||||
margin-left: 18px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.note-editor-theme_link {
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.note-editor-theme_text-strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
memo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import cn from 'classnames'
|
||||
import { NoteTheme } from '../../types'
|
||||
import { THEME_MAP } from '../../constants'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
|
||||
export const COLOR_LIST = [
|
||||
{
|
||||
key: NoteTheme.blue,
|
||||
inner: THEME_MAP[NoteTheme.blue].title,
|
||||
outer: THEME_MAP[NoteTheme.blue].outer,
|
||||
},
|
||||
{
|
||||
key: NoteTheme.cyan,
|
||||
inner: THEME_MAP[NoteTheme.cyan].title,
|
||||
outer: THEME_MAP[NoteTheme.cyan].outer,
|
||||
},
|
||||
{
|
||||
key: NoteTheme.green,
|
||||
inner: THEME_MAP[NoteTheme.green].title,
|
||||
outer: THEME_MAP[NoteTheme.green].outer,
|
||||
},
|
||||
{
|
||||
key: NoteTheme.yellow,
|
||||
inner: THEME_MAP[NoteTheme.yellow].title,
|
||||
outer: THEME_MAP[NoteTheme.yellow].outer,
|
||||
},
|
||||
{
|
||||
key: NoteTheme.pink,
|
||||
inner: THEME_MAP[NoteTheme.pink].title,
|
||||
outer: THEME_MAP[NoteTheme.pink].outer,
|
||||
},
|
||||
{
|
||||
key: NoteTheme.violet,
|
||||
inner: THEME_MAP[NoteTheme.violet].title,
|
||||
outer: THEME_MAP[NoteTheme.violet].outer,
|
||||
},
|
||||
]
|
||||
|
||||
export type ColorPickerProps = {
|
||||
theme: NoteTheme
|
||||
onThemeChange: (theme: NoteTheme) => void
|
||||
}
|
||||
const ColorPicker = ({
|
||||
theme,
|
||||
onThemeChange,
|
||||
}: ColorPickerProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='top'
|
||||
offset={4}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(!open)}>
|
||||
<div className={cn(
|
||||
'flex items-center justify-center w-8 h-8 rounded-md cursor-pointer hover:bg-black/5',
|
||||
open && 'bg-black/5',
|
||||
)}>
|
||||
<div
|
||||
className='w-4 h-4 rounded-full border border-black/5'
|
||||
style={{ backgroundColor: THEME_MAP[theme].title }}
|
||||
></div>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent>
|
||||
<div className='grid grid-cols-3 grid-rows-2 gap-0.5 p-0.5 rounded-lg border-[0.5px] border-black/8 bg-white shadow-lg'>
|
||||
{
|
||||
COLOR_LIST.map(color => (
|
||||
<div
|
||||
key={color.key}
|
||||
className='group relative flex items-center justify-center w-8 h-8 rounded-md cursor-pointer'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onThemeChange(color.key)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='hidden group-hover:block absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-5 h-5 rounded-full border-[1.5px]'
|
||||
style={{ borderColor: color.outer }}
|
||||
></div>
|
||||
<div
|
||||
className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-4 h-4 rounded-full border border-black/5'
|
||||
style={{ backgroundColor: color.inner }}
|
||||
></div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ColorPicker)
|
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
memo,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import cn from 'classnames'
|
||||
import { useStore } from '../store'
|
||||
import { useCommand } from './hooks'
|
||||
import { Link01 } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import {
|
||||
Bold01,
|
||||
Dotpoints01,
|
||||
Italic01,
|
||||
Strikethrough01,
|
||||
} from '@/app/components/base/icons/src/vender/line/editor'
|
||||
import TooltipPlus from '@/app/components/base/tooltip-plus'
|
||||
|
||||
type CommandProps = {
|
||||
type: 'bold' | 'italic' | 'strikethrough' | 'link' | 'bullet'
|
||||
}
|
||||
const Command = ({
|
||||
type,
|
||||
}: CommandProps) => {
|
||||
const { t } = useTranslation()
|
||||
const selectedIsBold = useStore(s => s.selectedIsBold)
|
||||
const selectedIsItalic = useStore(s => s.selectedIsItalic)
|
||||
const selectedIsStrikeThrough = useStore(s => s.selectedIsStrikeThrough)
|
||||
const selectedIsLink = useStore(s => s.selectedIsLink)
|
||||
const selectedIsBullet = useStore(s => s.selectedIsBullet)
|
||||
const { handleCommand } = useCommand()
|
||||
|
||||
const icon = useMemo(() => {
|
||||
switch (type) {
|
||||
case 'bold':
|
||||
return <Bold01 className={cn('w-4 h-4', selectedIsBold && 'text-primary-600')} />
|
||||
case 'italic':
|
||||
return <Italic01 className={cn('w-4 h-4', selectedIsItalic && 'text-primary-600')} />
|
||||
case 'strikethrough':
|
||||
return <Strikethrough01 className={cn('w-4 h-4', selectedIsStrikeThrough && 'text-primary-600')} />
|
||||
case 'link':
|
||||
return <Link01 className={cn('w-4 h-4', selectedIsLink && 'text-primary-600')} />
|
||||
case 'bullet':
|
||||
return <Dotpoints01 className={cn('w-4 h-4', selectedIsBullet && 'text-primary-600')} />
|
||||
}
|
||||
}, [type, selectedIsBold, selectedIsItalic, selectedIsStrikeThrough, selectedIsLink, selectedIsBullet])
|
||||
|
||||
const tip = useMemo(() => {
|
||||
switch (type) {
|
||||
case 'bold':
|
||||
return t('workflow.nodes.note.editor.bold')
|
||||
case 'italic':
|
||||
return t('workflow.nodes.note.editor.italic')
|
||||
case 'strikethrough':
|
||||
return t('workflow.nodes.note.editor.strikethrough')
|
||||
case 'link':
|
||||
return t('workflow.nodes.note.editor.link')
|
||||
case 'bullet':
|
||||
return t('workflow.nodes.note.editor.bulletList')
|
||||
}
|
||||
}, [type, t])
|
||||
|
||||
return (
|
||||
<TooltipPlus popupContent={tip}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center w-8 h-8 cursor-pointer rounded-md text-gray-500 hover:text-gray-800 hover:bg-black/5',
|
||||
type === 'bold' && selectedIsBold && 'bg-primary-50',
|
||||
type === 'italic' && selectedIsItalic && 'bg-primary-50',
|
||||
type === 'strikethrough' && selectedIsStrikeThrough && 'bg-primary-50',
|
||||
type === 'link' && selectedIsLink && 'bg-primary-50',
|
||||
type === 'bullet' && selectedIsBullet && 'bg-primary-50',
|
||||
)}
|
||||
onClick={() => handleCommand(type)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
</TooltipPlus>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Command)
|
@@ -0,0 +1,7 @@
|
||||
const Divider = () => {
|
||||
return (
|
||||
<div className='mx-1 w-[1px] h-3.5 bg-gray-200'></div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Divider
|
@@ -0,0 +1,86 @@
|
||||
import { memo } from 'react'
|
||||
import cn from 'classnames'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useFontSize } from './hooks'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { TitleCase } from '@/app/components/base/icons/src/vender/line/editor'
|
||||
import { ChevronDown } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
import { Check } from '@/app/components/base/icons/src/vender/line/general'
|
||||
|
||||
const FontSizeSelector = () => {
|
||||
const { t } = useTranslation()
|
||||
const FONT_SIZE_LIST = [
|
||||
{
|
||||
key: '12px',
|
||||
value: t('workflow.nodes.note.editor.small'),
|
||||
},
|
||||
{
|
||||
key: '14px',
|
||||
value: t('workflow.nodes.note.editor.medium'),
|
||||
},
|
||||
{
|
||||
key: '16px',
|
||||
value: t('workflow.nodes.note.editor.large'),
|
||||
},
|
||||
]
|
||||
const {
|
||||
fontSizeSelectorShow,
|
||||
handleOpenFontSizeSelector,
|
||||
fontSize,
|
||||
handleFontSize,
|
||||
} = useFontSize()
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={fontSizeSelectorShow}
|
||||
onOpenChange={handleOpenFontSizeSelector}
|
||||
placement='bottom-start'
|
||||
offset={2}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => handleOpenFontSizeSelector(!fontSizeSelectorShow)}>
|
||||
<div className={cn(
|
||||
'flex items-center pl-2 pr-1.5 h-8 rounded-md text-[13px] font-medium text-gray-700 cursor-pointer hover:bg-gray-50',
|
||||
fontSizeSelectorShow && 'bg-gray-50',
|
||||
)}>
|
||||
<TitleCase className='mr-1 w-4 h-4' />
|
||||
{FONT_SIZE_LIST.find(font => font.key === fontSize)?.value || t('workflow.nodes.note.editor.small')}
|
||||
<ChevronDown className='ml-0.5 w-3 h-3' />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent>
|
||||
<div className='p-1 w-[120px] bg-white border-[0.5px] border-gray-200 rounded-md shadow-xl text-gray-700'>
|
||||
{
|
||||
FONT_SIZE_LIST.map(font => (
|
||||
<div
|
||||
key={font.key}
|
||||
className='flex items-center justify-between pl-3 pr-2 h-8 rounded-md cursor-pointer hover:bg-gray-50'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleFontSize(font.key)
|
||||
handleOpenFontSizeSelector(false)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ fontSize: font.key }}
|
||||
>
|
||||
{font.value}
|
||||
</div>
|
||||
{
|
||||
fontSize === font.key && (
|
||||
<Check className='w-4 h-4 text-primary-500' />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(FontSizeSelector)
|
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
$createParagraphNode,
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
$setSelection,
|
||||
COMMAND_PRIORITY_CRITICAL,
|
||||
FORMAT_TEXT_COMMAND,
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
} from 'lexical'
|
||||
import {
|
||||
$getSelectionStyleValueForProperty,
|
||||
$patchStyleText,
|
||||
$setBlocksType,
|
||||
} from '@lexical/selection'
|
||||
import { INSERT_UNORDERED_LIST_COMMAND } from '@lexical/list'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import {
|
||||
$isLinkNode,
|
||||
TOGGLE_LINK_COMMAND,
|
||||
} from '@lexical/link'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { useNoteEditorStore } from '../store'
|
||||
import { getSelectedNode } from '../utils'
|
||||
|
||||
export const useCommand = () => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const noteEditorStore = useNoteEditorStore()
|
||||
|
||||
const handleCommand = useCallback((type: string) => {
|
||||
if (type === 'bold')
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold')
|
||||
|
||||
if (type === 'italic')
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic')
|
||||
|
||||
if (type === 'strikethrough')
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough')
|
||||
|
||||
if (type === 'link') {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection()
|
||||
|
||||
if ($isRangeSelection(selection)) {
|
||||
const node = getSelectedNode(selection)
|
||||
const parent = node.getParent()
|
||||
const { setLinkAnchorElement } = noteEditorStore.getState()
|
||||
|
||||
if ($isLinkNode(parent) || $isLinkNode(node)) {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
|
||||
setLinkAnchorElement()
|
||||
}
|
||||
else {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, '')
|
||||
setLinkAnchorElement(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (type === 'bullet') {
|
||||
const { selectedIsBullet } = noteEditorStore.getState()
|
||||
|
||||
if (selectedIsBullet) {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection()
|
||||
if ($isRangeSelection(selection))
|
||||
$setBlocksType(selection, () => $createParagraphNode())
|
||||
})
|
||||
}
|
||||
else {
|
||||
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined)
|
||||
}
|
||||
}
|
||||
}, [editor, noteEditorStore])
|
||||
|
||||
return {
|
||||
handleCommand,
|
||||
}
|
||||
}
|
||||
|
||||
export const useFontSize = () => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const [fontSize, setFontSize] = useState('12px')
|
||||
const [fontSizeSelectorShow, setFontSizeSelectorShow] = useState(false)
|
||||
|
||||
const handleFontSize = useCallback((fontSize: string) => {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection()
|
||||
|
||||
if ($isRangeSelection(selection))
|
||||
$patchStyleText(selection, { 'font-size': fontSize })
|
||||
})
|
||||
}, [editor])
|
||||
|
||||
const handleOpenFontSizeSelector = useCallback((newFontSizeSelectorShow: boolean) => {
|
||||
if (newFontSizeSelectorShow) {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection()
|
||||
|
||||
if ($isRangeSelection(selection))
|
||||
$setSelection(selection.clone())
|
||||
})
|
||||
}
|
||||
setFontSizeSelectorShow(newFontSizeSelectorShow)
|
||||
}, [editor])
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
editor.registerUpdateListener(() => {
|
||||
editor.getEditorState().read(() => {
|
||||
const selection = $getSelection()
|
||||
|
||||
if ($isRangeSelection(selection)) {
|
||||
const fontSize = $getSelectionStyleValueForProperty(selection, 'font-size', '12px')
|
||||
setFontSize(fontSize)
|
||||
}
|
||||
})
|
||||
}),
|
||||
editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
() => {
|
||||
const selection = $getSelection()
|
||||
|
||||
if ($isRangeSelection(selection)) {
|
||||
const fontSize = $getSelectionStyleValueForProperty(selection, 'font-size', '12px')
|
||||
setFontSize(fontSize)
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_CRITICAL,
|
||||
),
|
||||
)
|
||||
}, [editor])
|
||||
|
||||
return {
|
||||
fontSize,
|
||||
handleFontSize,
|
||||
fontSizeSelectorShow,
|
||||
handleOpenFontSizeSelector,
|
||||
}
|
||||
}
|
@@ -0,0 +1,48 @@
|
||||
import { memo } from 'react'
|
||||
import Divider from './divider'
|
||||
import type { ColorPickerProps } from './color-picker'
|
||||
import ColorPicker from './color-picker'
|
||||
import FontSizeSelector from './font-size-selector'
|
||||
import Command from './command'
|
||||
import type { OperatorProps } from './operator'
|
||||
import Operator from './operator'
|
||||
|
||||
type ToolbarProps = ColorPickerProps & OperatorProps
|
||||
const Toolbar = ({
|
||||
theme,
|
||||
onThemeChange,
|
||||
onCopy,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
showAuthor,
|
||||
onShowAuthorChange,
|
||||
}: ToolbarProps) => {
|
||||
return (
|
||||
<div className='inline-flex items-center p-0.5 bg-white rounded-lg border-[0.5px] border-black/5 shadow-sm'>
|
||||
<ColorPicker
|
||||
theme={theme}
|
||||
onThemeChange={onThemeChange}
|
||||
/>
|
||||
<Divider />
|
||||
<FontSizeSelector />
|
||||
<Divider />
|
||||
<div className='flex items-center space-x-0.5'>
|
||||
<Command type='bold' />
|
||||
<Command type='italic' />
|
||||
<Command type='strikethrough' />
|
||||
<Command type='link' />
|
||||
<Command type='bullet' />
|
||||
</div>
|
||||
<Divider />
|
||||
<Operator
|
||||
onCopy={onCopy}
|
||||
onDuplicate={onDuplicate}
|
||||
onDelete={onDelete}
|
||||
showAuthor={showAuthor}
|
||||
onShowAuthorChange={onShowAuthorChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Toolbar)
|
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
memo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import cn from 'classnames'
|
||||
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { DotsHorizontal } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
|
||||
export type OperatorProps = {
|
||||
onCopy: () => void
|
||||
onDuplicate: () => void
|
||||
onDelete: () => void
|
||||
showAuthor: boolean
|
||||
onShowAuthorChange: (showAuthor: boolean) => void
|
||||
}
|
||||
const Operator = ({
|
||||
onCopy,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
showAuthor,
|
||||
onShowAuthorChange,
|
||||
}: OperatorProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='bottom-end'
|
||||
offset={4}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(!open)}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center w-8 h-8 cursor-pointer rounded-lg hover:bg-black/5',
|
||||
open && 'bg-black/5',
|
||||
)}
|
||||
>
|
||||
<DotsHorizontal className='w-4 h-4 text-gray-500' />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent>
|
||||
<div className='min-w-[192px] bg-white rounded-md border-[0.5px] border-gray-200 shadow-xl'>
|
||||
<div className='p-1'>
|
||||
<div
|
||||
className='flex items-center justify-between px-3 h-8 cursor-pointer rounded-md text-sm text-gray-700 hover:bg-black/5'
|
||||
onClick={() => {
|
||||
onCopy()
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{t('workflow.common.copy')}
|
||||
<ShortcutsName keys={['ctrl', 'c']} />
|
||||
</div>
|
||||
<div
|
||||
className='flex items-center justify-between px-3 h-8 cursor-pointer rounded-md text-sm text-gray-700 hover:bg-black/5'
|
||||
onClick={() => {
|
||||
onDuplicate()
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{t('workflow.common.duplicate')}
|
||||
<ShortcutsName keys={['ctrl', 'd']} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='h-[1px] bg-gray-100'></div>
|
||||
<div className='p-1'>
|
||||
<div
|
||||
className='flex items-center justify-between px-3 h-8 cursor-pointer rounded-md text-sm text-gray-700 hover:bg-black/5'
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div>{t('workflow.nodes.note.editor.showAuthor')}</div>
|
||||
<Switch
|
||||
size='l'
|
||||
defaultValue={showAuthor}
|
||||
onChange={onShowAuthorChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='h-[1px] bg-gray-100'></div>
|
||||
<div className='p-1'>
|
||||
<div
|
||||
className='flex items-center justify-between px-3 h-8 cursor-pointer rounded-md text-sm text-gray-700 hover:text-[#D92D20] hover:bg-[#FEF3F2]'
|
||||
onClick={() => {
|
||||
onDelete()
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{t('common.operation.delete')}
|
||||
<ShortcutsName keys={['del']} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Operator)
|
21
web/app/components/workflow/note-node/note-editor/utils.ts
Normal file
21
web/app/components/workflow/note-node/note-editor/utils.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { $isAtNodeEnd } from '@lexical/selection'
|
||||
import type { ElementNode, RangeSelection, TextNode } from 'lexical'
|
||||
|
||||
export function getSelectedNode(
|
||||
selection: RangeSelection,
|
||||
): TextNode | ElementNode {
|
||||
const anchor = selection.anchor
|
||||
const focus = selection.focus
|
||||
const anchorNode = selection.anchor.getNode()
|
||||
const focusNode = selection.focus.getNode()
|
||||
if (anchorNode === focusNode)
|
||||
return anchorNode
|
||||
|
||||
const isBackward = selection.isBackward()
|
||||
if (isBackward)
|
||||
return $isAtNodeEnd(focus) ? anchorNode : focusNode
|
||||
else
|
||||
return $isAtNodeEnd(anchor) ? anchorNode : focusNode
|
||||
}
|
||||
|
||||
export const urlRegExp = /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w-_]*)?\??(?:[-+=&;%@.\w_]*)#?(?:[\w]*))?)/
|
17
web/app/components/workflow/note-node/types.ts
Normal file
17
web/app/components/workflow/note-node/types.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { CommonNodeType } from '../types'
|
||||
|
||||
export enum NoteTheme {
|
||||
blue = 'blue',
|
||||
cyan = 'cyan',
|
||||
green = 'green',
|
||||
yellow = 'yellow',
|
||||
pink = 'pink',
|
||||
violet = 'violet',
|
||||
}
|
||||
|
||||
export type NoteNodeType = CommonNodeType & {
|
||||
text: string
|
||||
theme: NoteTheme
|
||||
author: string
|
||||
showAuthor: boolean
|
||||
}
|
@@ -1,4 +1,8 @@
|
||||
import { memo, useCallback } from 'react'
|
||||
import type { MouseEvent } from 'react'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import cn from 'classnames'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
@@ -11,6 +15,7 @@ import { isEventTargetInputArea } from '../utils'
|
||||
import { useStore } from '../store'
|
||||
import AddBlock from './add-block'
|
||||
import TipPopup from './tip-popup'
|
||||
import { useOperator } from './hooks'
|
||||
import {
|
||||
Cursor02C,
|
||||
Hand02,
|
||||
@@ -20,12 +25,14 @@ import {
|
||||
Hand02 as Hand02Solid,
|
||||
} from '@/app/components/base/icons/src/vender/solid/editor'
|
||||
import { OrganizeGrid } from '@/app/components/base/icons/src/vender/line/layout'
|
||||
import { StickerSquare } from '@/app/components/base/icons/src/vender/line/files'
|
||||
|
||||
const Control = () => {
|
||||
const { t } = useTranslation()
|
||||
const controlMode = useStore(s => s.controlMode)
|
||||
const setControlMode = useStore(s => s.setControlMode)
|
||||
const { handleLayout } = useWorkflow()
|
||||
const { handleAddNote } = useOperator()
|
||||
const {
|
||||
nodesReadOnly,
|
||||
getNodesReadOnly,
|
||||
@@ -75,9 +82,28 @@ const Control = () => {
|
||||
handleLayout()
|
||||
}
|
||||
|
||||
const addNote = (e: MouseEvent<HTMLDivElement>) => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
e.stopPropagation()
|
||||
handleAddNote()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex items-center p-0.5 rounded-lg border-[0.5px] border-gray-100 bg-white shadow-lg text-gray-500'>
|
||||
<AddBlock />
|
||||
<TipPopup title={t('workflow.nodes.note.addNote')}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center ml-[1px] w-8 h-8 rounded-lg hover:bg-black/5 hover:text-gray-700 cursor-pointer',
|
||||
`${nodesReadOnly && '!cursor-not-allowed opacity-50'}`,
|
||||
)}
|
||||
onClick={addNote}
|
||||
>
|
||||
<StickerSquare />
|
||||
</div>
|
||||
</TipPopup>
|
||||
<div className='mx-[3px] w-[1px] h-3.5 bg-gray-200'></div>
|
||||
<TipPopup title={t('workflow.common.pointerMode')}>
|
||||
<div
|
||||
|
41
web/app/components/workflow/operator/hooks.ts
Normal file
41
web/app/components/workflow/operator/hooks.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useCallback } from 'react'
|
||||
import { generateNewNode } from '../utils'
|
||||
import { useWorkflowStore } from '../store'
|
||||
import type { NoteNodeType } from '../note-node/types'
|
||||
import { CUSTOM_NOTE_NODE } from '../note-node/constants'
|
||||
import { NoteTheme } from '../note-node/types'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
|
||||
export const useOperator = () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { userProfile } = useAppContext()
|
||||
|
||||
const handleAddNote = useCallback(() => {
|
||||
const newNode = generateNewNode({
|
||||
type: CUSTOM_NOTE_NODE,
|
||||
data: {
|
||||
title: '',
|
||||
desc: '',
|
||||
type: '' as any,
|
||||
text: '',
|
||||
theme: NoteTheme.blue,
|
||||
author: userProfile?.name || '',
|
||||
showAuthor: true,
|
||||
width: 240,
|
||||
height: 88,
|
||||
_isCandidate: true,
|
||||
} as NoteNodeType,
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
})
|
||||
workflowStore.setState({
|
||||
candidateNode: newNode,
|
||||
})
|
||||
}, [workflowStore, userProfile])
|
||||
|
||||
return {
|
||||
handleAddNote,
|
||||
}
|
||||
}
|
@@ -13,6 +13,7 @@ import {
|
||||
useWorkflowStartRun,
|
||||
} from './hooks'
|
||||
import AddBlock from './operator/add-block'
|
||||
import { useOperator } from './operator/hooks'
|
||||
import { exportAppConfig } from '@/service/apps'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
@@ -27,6 +28,7 @@ const PanelContextmenu = () => {
|
||||
const { handleNodesPaste } = useNodesInteractions()
|
||||
const { handlePaneContextmenuCancel } = usePanelInteractions()
|
||||
const { handleStartWorkflowRun } = useWorkflowStartRun()
|
||||
const { handleAddNote } = useOperator()
|
||||
|
||||
useClickAway(() => {
|
||||
handlePaneContextmenuCancel()
|
||||
@@ -78,6 +80,16 @@ const PanelContextmenu = () => {
|
||||
crossAxis: -4,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className='flex items-center justify-between px-3 h-8 text-sm text-gray-700 rounded-lg cursor-pointer hover:bg-gray-50'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleAddNote()
|
||||
handlePaneContextmenuCancel()
|
||||
}}
|
||||
>
|
||||
{t('workflow.nodes.note.addNote')}
|
||||
</div>
|
||||
<div
|
||||
className='flex items-center justify-between px-3 h-8 text-sm text-gray-700 rounded-lg cursor-pointer hover:bg-gray-50'
|
||||
onClick={() => {
|
||||
|
@@ -17,6 +17,7 @@ import type {
|
||||
} from './types'
|
||||
import { BlockEnum } from './types'
|
||||
import {
|
||||
CUSTOM_NODE,
|
||||
ITERATION_NODE_Z_INDEX,
|
||||
NODE_WIDTH_X_OFFSET,
|
||||
START_INITIAL_POSITION,
|
||||
@@ -105,7 +106,8 @@ export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => {
|
||||
}, {} as Record<string, string[]>)
|
||||
|
||||
return nodes.map((node) => {
|
||||
node.type = 'custom'
|
||||
if (!node.type)
|
||||
node.type = CUSTOM_NODE
|
||||
|
||||
const connectedEdges = getConnectedEdges([node], edges)
|
||||
node.data._connectedSourceHandleIds = connectedEdges.filter(edge => edge.source === node.id).map(edge => edge.sourceHandle || 'source')
|
||||
@@ -189,7 +191,7 @@ export const initialEdges = (originEdges: Edge[], originNodes: Node[]) => {
|
||||
export const getLayoutByDagre = (originNodes: Node[], originEdges: Edge[]) => {
|
||||
const dagreGraph = new dagre.graphlib.Graph()
|
||||
dagreGraph.setDefaultEdgeLabel(() => ({}))
|
||||
const nodes = cloneDeep(originNodes).filter(node => !node.parentId)
|
||||
const nodes = cloneDeep(originNodes).filter(node => !node.parentId && node.type === CUSTOM_NODE)
|
||||
const edges = cloneDeep(originEdges).filter(edge => !edge.data?.isInIteration)
|
||||
dagreGraph.setGraph({
|
||||
rankdir: 'LR',
|
||||
@@ -280,10 +282,10 @@ export const getNodesConnectedSourceOrTargetHandleIdsMap = (changes: ConnectedSo
|
||||
return nodesConnectedSourceOrTargetHandleIdsMap
|
||||
}
|
||||
|
||||
export const generateNewNode = ({ data, position, id, zIndex, ...rest }: Omit<Node, 'id'> & { id?: string }) => {
|
||||
export const generateNewNode = ({ data, position, id, zIndex, type, ...rest }: Omit<Node, 'id'> & { id?: string }) => {
|
||||
return {
|
||||
id: id || `${Date.now()}`,
|
||||
type: 'custom',
|
||||
type: type || CUSTOM_NODE,
|
||||
data,
|
||||
position,
|
||||
targetPosition: Position.Left,
|
||||
|
Reference in New Issue
Block a user