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