This commit is contained in:
2025-12-01 17:21:38 +08:00
parent 32fee2b8ab
commit fab8c13cb3
7511 changed files with 996300 additions and 0 deletions

View 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: 'border-util-colors-blue-blue-500',
title: 'bg-util-colors-blue-blue-100',
bg: 'bg-util-colors-blue-blue-50',
border: 'border-util-colors-blue-blue-300',
},
[NoteTheme.cyan]: {
outer: 'border-util-colors-cyan-cyan-500',
title: 'bg-util-colors-cyan-cyan-100',
bg: 'bg-util-colors-cyan-cyan-50',
border: 'border-util-colors-cyan-cyan-300',
},
[NoteTheme.green]: {
outer: 'border-util-colors-green-green-500',
title: 'bg-util-colors-green-green-100',
bg: 'bg-util-colors-green-green-50',
border: 'border-util-colors-green-green-300',
},
[NoteTheme.yellow]: {
outer: 'border-util-colors-yellow-yellow-500',
title: 'bg-util-colors-yellow-yellow-100',
bg: 'bg-util-colors-yellow-yellow-50',
border: 'border-util-colors-yellow-yellow-300',
},
[NoteTheme.pink]: {
outer: 'border-util-colors-pink-pink-500',
title: 'bg-util-colors-pink-pink-100',
bg: 'bg-util-colors-pink-pink-50',
border: 'border-util-colors-pink-pink-300',
},
[NoteTheme.violet]: {
outer: 'border-util-colors-violet-violet-500',
title: 'bg-util-colors-violet-violet-100',
bg: 'bg-util-colors-violet-violet-100',
border: 'border-util-colors-violet-violet-300',
},
}

View File

@@ -0,0 +1,32 @@
import { useCallback } from 'react'
import type { EditorState } from 'lexical'
import { WorkflowHistoryEvent, useNodeDataUpdate, useWorkflowHistory } from '../hooks'
import type { NoteTheme } from './types'
export const useNote = (id: string) => {
const { handleNodeDataUpdateWithSyncDraft } = useNodeDataUpdate()
const { saveStateToHistory } = useWorkflowHistory()
const handleThemeChange = useCallback((theme: NoteTheme) => {
handleNodeDataUpdateWithSyncDraft({ id, data: { theme } })
saveStateToHistory(WorkflowHistoryEvent.NoteChange, { nodeId: id })
}, [handleNodeDataUpdateWithSyncDraft, id, saveStateToHistory])
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 } })
saveStateToHistory(WorkflowHistoryEvent.NoteChange, { nodeId: id })
}, [handleNodeDataUpdateWithSyncDraft, id, saveStateToHistory])
return {
handleThemeChange,
handleEditorChange,
handleShowAuthorChange,
}
}

View File

@@ -0,0 +1,134 @@
import {
memo,
useRef,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useClickAway } from 'ahooks'
import type { NodeProps } from 'reactflow'
import NodeResizer from '../nodes/_base/components/node-resizer'
import { useWorkflowHistoryStore } from '../workflow-history-store'
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'
import cn from '@/utils/classnames'
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()
useClickAway(() => {
handleNodeDataUpdateWithSyncDraft({ id, data: { selected: false } })
}, ref)
const { setShortcutsEnabled } = useWorkflowHistoryStore()
return (
<div
className={cn(
'relative flex flex-col rounded-md border shadow-xs hover:shadow-md',
THEME_MAP[theme].bg,
data.selected ? THEME_MAP[theme].border : 'border-black/5',
)}
style={{
width: data.width,
height: data.height,
}}
ref={ref}
>
<NoteEditorContextProvider
key={controlPromptEditorRerenderKey}
value={data.text}
editable={!data._isTempNode}
>
<>
{
!data._isTempNode && (
<NodeResizer
nodeId={id}
nodeData={data}
icon={<Icon />}
minWidth={240}
minHeight={88}
/>
)
}
<div
className={cn(
'h-2 shrink-0 rounded-t-md opacity-50',
THEME_MAP[theme].title,
)}></div>
{
data.selected && !data._isTempNode && (
<div className='absolute left-1/2 top-[-41px] -translate-x-1/2'>
<NoteEditorToolbar
theme={theme}
onThemeChange={handleThemeChange}
onCopy={() => handleNodesCopy(id)}
onDuplicate={() => handleNodesDuplicate(id)}
onDelete={() => handleNodeDelete(id)}
showAuthor={data.showAuthor}
onShowAuthorChange={handleShowAuthorChange}
/>
</div>
)
}
<div className='grow overflow-y-auto px-3 py-2.5'>
<div className={cn(
data.selected && 'nodrag nopan nowheel cursor-text',
)}>
<NoteEditor
containerElement={ref.current}
placeholder={t('workflow.nodes.note.editor.placeholder') || ''}
onChange={handleEditorChange}
setShortcutsEnabled={setShortcutsEnabled}
/>
</div>
</div>
{
data.showAuthor && (
<div className='p-3 pt-0 text-xs text-text-tertiary'>
{data.author}
</div>
)
}
</>
</NoteEditorContextProvider>
</div>
)
}
export default memo(NoteNode)

View File

@@ -0,0 +1,68 @@
'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: React.JSX.Element | string | (React.JSX.Element | string)[]
editable?: boolean
}
export const NoteEditorContextProvider = memo(({
value,
children,
editable = true,
}: NoteEditorContextProviderProps) => {
const storeRef = useRef<NoteEditorStore | undefined>(undefined)
if (!storeRef.current)
storeRef.current = createNoteEditorStore()
let initialValue = null
try {
initialValue = JSON.parse(value)
}
catch {
}
const initialConfig = {
namespace: 'note-editor',
nodes: [
LinkNode,
ListNode,
ListItemNode,
],
editorState: !initialValue?.root.children.length ? null : JSON.stringify(initialValue),
onError: (error: Error) => {
throw error
},
theme,
editable,
}
return (
<NoteEditorContext.Provider value={storeRef.current}>
<LexicalComposer initialConfig={{ ...initialConfig }}>
{children}
</LexicalComposer>
</NoteEditorContext.Provider>
)
})
NoteEditorContextProvider.displayName = 'NoteEditorContextProvider'
export default NoteEditorContext

View File

@@ -0,0 +1,65 @@
'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
setShortcutsEnabled?: (v: boolean) => void
}
const Editor = ({
placeholder = 'write you note...',
onChange,
containerElement,
setShortcutsEnabled,
}: EditorProps) => {
const handleEditorChange = useCallback((editorState: EditorState) => {
onChange?.(editorState)
}, [onChange])
return (
<div className='relative'>
<RichTextPlugin
contentEditable={
<div>
<ContentEditable
onFocus={() => setShortcutsEnabled?.(false)}
onBlur={() => setShortcutsEnabled?.(true)}
spellCheck={false}
className='h-full w-full text-text-secondary caret-primary-600 outline-none'
/>
</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)

View File

@@ -0,0 +1,3 @@
export { NoteEditorContextProvider } from './context'
export { default as NoteEditor } from './editor'
export { default as NoteEditorToolbar } from './toolbar'

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,149 @@
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 {
RiEditLine,
RiExternalLinkLine,
RiLinkUnlinkM,
} from '@remixicon/react'
import { useStore } from '../../store'
import { useLink } from './hooks'
import cn from '@/utils/classnames'
import Button from '@/app/components/base/button'
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 z-10 inline-flex w-max items-center rounded-md border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg',
!linkOperatorShow && 'p-1 shadow-md',
linkOperatorShow && 'system-xs-medium p-0.5 text-text-tertiary shadow-sm',
)}
style={floatingStyles}
ref={refs.setFloating}
>
{
!linkOperatorShow && (
<>
<input
className='mr-0.5 h-6 w-[196px] appearance-none rounded-sm bg-transparent p-1 text-[13px] text-components-input-text-filled outline-none'
value={url}
onChange={e => setUrl(e.target.value)}
placeholder={t('workflow.nodes.note.editor.enterUrl') || ''}
autoFocus
/>
<Button
variant='primary'
size='small'
disabled={!url}
onClick={() => handleSaveLink(url)}
>
{t('common.operation.ok')}
</Button>
</>
)
}
{
linkOperatorShow && (
<>
<a
className='flex h-6 items-center rounded-md px-2 hover:bg-state-base-hover'
href={escape(url)}
target='_blank'
rel='noreferrer'
>
<RiExternalLinkLine className='mr-1 h-3 w-3' />
<div className='mr-1'>
{t('workflow.nodes.note.editor.openLink')}
</div>
<div
title={escape(url)}
className='max-w-[140px] truncate text-text-accent'
>
{escape(url)}
</div>
</a>
<div className='mx-1 h-3.5 w-[1px] bg-divider-regular'></div>
<div
className='mr-0.5 flex h-6 cursor-pointer items-center rounded-md px-2 hover:bg-state-base-hover'
onClick={(e) => {
e.stopPropagation()
setLinkOperatorShow(false)
}}
>
<RiEditLine className='mr-1 h-3 w-3' />
{t('common.operation.edit')}
</div>
<div
className='flex h-6 cursor-pointer items-center rounded-md px-2 hover:bg-state-base-hover'
onClick={handleUnlink}
>
<RiLinkUnlinkM className='mr-1 h-3 w-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)

View 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)!
}

View File

@@ -0,0 +1,18 @@
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: {
italic: 'note-editor-theme_text-italic',
strikethrough: 'note-editor-theme_text-strikethrough',
},
}
export default theme

View File

@@ -0,0 +1,32 @@
.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 {
cursor: pointer;
color: var(--text-text-selected);
}
.note-editor-theme_link:hover {
text-decoration: underline;
}
.note-editor-theme_text-strikethrough {
text-decoration: line-through;
}
.note-editor-theme_text-italic {
font-style: italic;
}

View File

@@ -0,0 +1,111 @@
import {
memo,
useState,
} from 'react'
import { NoteTheme } from '../../types'
import { THEME_MAP } from '../../constants'
import cn from '@/utils/classnames'
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 h-8 w-8 cursor-pointer items-center justify-center rounded-md hover:bg-black/5',
open && 'bg-black/5',
)}>
<div
className={cn(
'h-4 w-4 rounded-full border border-black/5',
THEME_MAP[theme].title,
)}
></div>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent>
<div className='grid grid-cols-3 grid-rows-2 gap-0.5 rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-lg'>
{
COLOR_LIST.map(color => (
<div
key={color.key}
className='group relative flex h-8 w-8 cursor-pointer items-center justify-center rounded-md'
onClick={(e) => {
e.stopPropagation()
onThemeChange(color.key)
setOpen(false)
}}
>
<div
className={cn(
'absolute left-1/2 top-1/2 hidden h-5 w-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-[1.5px] group-hover:block',
color.outer,
)}
></div>
<div
className={cn(
'absolute left-1/2 top-1/2 h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full border border-black/5',
color.inner,
)}
></div>
</div>
))
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default memo(ColorPicker)

View File

@@ -0,0 +1,83 @@
import {
memo,
useMemo,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
RiBold,
RiItalic,
RiLink,
RiListUnordered,
RiStrikethrough,
} from '@remixicon/react'
import { useStore } from '../store'
import { useCommand } from './hooks'
import cn from '@/utils/classnames'
import Tooltip from '@/app/components/base/tooltip'
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 <RiBold className={cn('h-4 w-4', selectedIsBold && 'text-primary-600')} />
case 'italic':
return <RiItalic className={cn('h-4 w-4', selectedIsItalic && 'text-primary-600')} />
case 'strikethrough':
return <RiStrikethrough className={cn('h-4 w-4', selectedIsStrikeThrough && 'text-primary-600')} />
case 'link':
return <RiLink className={cn('h-4 w-4', selectedIsLink && 'text-primary-600')} />
case 'bullet':
return <RiListUnordered className={cn('h-4 w-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 (
<Tooltip
popupContent={tip}
>
<div
className={cn(
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-text-tertiary hover:bg-state-accent-active hover:text-text-accent',
type === 'bold' && selectedIsBold && 'bg-state-accent-active',
type === 'italic' && selectedIsItalic && 'bg-state-accent-active',
type === 'strikethrough' && selectedIsStrikeThrough && 'bg-state-accent-active',
type === 'link' && selectedIsLink && 'bg-state-accent-active',
type === 'bullet' && selectedIsBullet && 'bg-state-accent-active',
)}
onClick={() => handleCommand(type)}
>
{icon}
</div>
</Tooltip>
)
}
export default memo(Command)

View File

@@ -0,0 +1,7 @@
const Divider = () => {
return (
<div className='mx-1 h-3.5 w-[1px] bg-divider-regular'></div>
)
}
export default Divider

View File

@@ -0,0 +1,84 @@
import { memo } from 'react'
import { RiFontSize } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useFontSize } from './hooks'
import cn from '@/utils/classnames'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
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 h-8 cursor-pointer items-center rounded-md pl-2 pr-1.5 text-[13px] font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
fontSizeSelectorShow && 'bg-state-base-hover text-text-secondary',
)}>
<RiFontSize className='mr-1 h-4 w-4' />
{FONT_SIZE_LIST.find(font => font.key === fontSize)?.value || t('workflow.nodes.note.editor.small')}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent>
<div className='w-[120px] rounded-md border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 text-text-secondary shadow-xl'>
{
FONT_SIZE_LIST.map(font => (
<div
key={font.key}
className='flex h-8 cursor-pointer items-center justify-between rounded-md pl-3 pr-2 hover:bg-state-base-hover'
onClick={(e) => {
e.stopPropagation()
handleFontSize(font.key)
handleOpenFontSizeSelector(false)
}}
>
<div
style={{ fontSize: font.key }}
>
{font.value}
</div>
{
fontSize === font.key && (
<Check className='h-4 w-4 text-text-accent' />
)
}
</div>
))
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default memo(FontSizeSelector)

View File

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

View File

@@ -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 rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.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)

View File

@@ -0,0 +1,107 @@
import {
memo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { RiMoreFill } from '@remixicon/react'
import cn from '@/utils/classnames'
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
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 h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
open && 'bg-state-base-hover text-text-secondary',
)}
>
<RiMoreFill className='h-4 w-4' />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent>
<div className='min-w-[192px] rounded-md border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xl'>
<div className='p-1'>
<div
className='flex h-8 cursor-pointer items-center justify-between rounded-md px-3 text-sm text-text-secondary hover:bg-state-base-hover'
onClick={() => {
onCopy()
setOpen(false)
}}
>
{t('workflow.common.copy')}
<ShortcutsName keys={['ctrl', 'c']} />
</div>
<div
className='flex h-8 cursor-pointer items-center justify-between rounded-md px-3 text-sm text-text-secondary hover:bg-state-base-hover'
onClick={() => {
onDuplicate()
setOpen(false)
}}
>
{t('workflow.common.duplicate')}
<ShortcutsName keys={['ctrl', 'd']} />
</div>
</div>
<div className='h-px bg-divider-subtle'></div>
<div className='p-1'>
<div
className='flex h-8 cursor-pointer items-center justify-between rounded-md px-3 text-sm text-text-secondary hover:bg-state-base-hover'
onClick={e => e.stopPropagation()}
>
<div>{t('workflow.nodes.note.editor.showAuthor')}</div>
<Switch
size='l'
defaultValue={showAuthor}
onChange={onShowAuthorChange}
/>
</div>
</div>
<div className='h-px bg-divider-subtle'></div>
<div className='p-1'>
<div
className='flex h-8 cursor-pointer items-center justify-between rounded-md px-3 text-sm text-text-secondary hover:bg-state-destructive-hover hover:text-text-destructive'
onClick={() => {
onDelete()
setOpen(false)
}}
>
{t('common.operation.delete')}
<ShortcutsName keys={['del']} />
</div>
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default memo(Operator)

View File

@@ -0,0 +1,22 @@
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
}
// eslint-disable-next-line sonarjs/empty-string-repetition
export const urlRegExp = /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w-_]*)?\??(?:[-+=&;%@.\w_]*)#?(?:[\w]*))?)/

View 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
}