feat: workflow add note node (#5164)
This commit is contained in:
@@ -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)
|
Reference in New Issue
Block a user