feat: workflow add note node (#5164)

This commit is contained in:
zxhlyh
2024-06-14 17:08:11 +08:00
committed by GitHub
parent d7fbae286a
commit c28d709d7f
69 changed files with 2375 additions and 169 deletions

View File

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

View File

@@ -0,0 +1,9 @@
import { useFormatDetector } from './hooks'
const FormatDetectorPlugin = () => {
useFormatDetector()
return null
}
export default FormatDetectorPlugin

View File

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

View File

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

View File

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