fix: improve note node (#35461)

This commit is contained in:
非法操作 2026-04-23 16:54:56 +08:00 committed by GitHub
parent 1c5d62d98a
commit 38e831c1b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 288 additions and 15 deletions

View File

@ -17,6 +17,15 @@ import DatasetSidebarDropdown from './dataset-sidebar-dropdown'
import NavLink from './nav-link'
import ToggleButton from './toggle-button'
const isShortcutFromInputArea = (target: EventTarget | null) => {
if (!(target instanceof HTMLElement))
return false
return target.tagName === 'INPUT'
|| target.tagName === 'TEXTAREA'
|| target.isContentEditable
}
type IAppDetailNavProps = {
iconType?: 'app' | 'dataset'
navigation: Array<{
@ -70,6 +79,9 @@ const AppDetailNav = ({
}, [appSidebarExpand, setAppSidebarExpand])
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.b`, (e) => {
if (isShortcutFromInputArea(e.target))
return
e.preventDefault()
handleToggle()
}, { exactMatch: true, useCapture: true })

View File

@ -4,6 +4,7 @@ import { createEdge, createNode } from '../../__tests__/fixtures'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { collaborationManager } from '../../collaboration/core/collaboration-manager'
import { CUSTOM_NOTE_NODE } from '../../note-node/constants'
import { BlockEnum, ControlMode } from '../../types'
import { useNodesInteractions } from '../use-nodes-interactions'
@ -317,6 +318,41 @@ describe('useNodesInteractions', () => {
expect(rfState.setEdges).not.toHaveBeenCalled()
})
it('ignores note node selection when clicking a linked text target', () => {
currentNodes = [
createNode({
id: 'note-1',
type: CUSTOM_NOTE_NODE,
data: {
type: '' as unknown as BlockEnum,
title: 'Note',
desc: '',
selected: false,
},
}),
]
currentEdges = []
rfState.nodes = currentNodes as unknown as typeof rfState.nodes
rfState.edges = currentEdges as unknown as typeof rfState.edges
const { result } = renderWorkflowHook(() => useNodesInteractions(), {
historyStore: {
nodes: currentNodes,
edges: currentEdges,
},
})
const link = document.createElement('a')
link.className = 'note-editor-theme_link'
act(() => {
result.current.handleNodeClick({ target: link } as never, currentNodes[0] as Node)
})
expect(rfState.setNodes).not.toHaveBeenCalled()
expect(rfState.setEdges).not.toHaveBeenCalled()
})
it('updates entering states on node enter and clears them on leave using collaborative workflow state', () => {
currentNodes = [
createNode({

View File

@ -137,6 +137,12 @@ const getUniquePastedNodeTitle = (
return titleCandidate
}
const isNoteLinkClickTarget = (target: EventTarget | null, node: Node) => {
return node.type === CUSTOM_NOTE_NODE
&& target instanceof HTMLElement
&& !!target.closest('.note-editor-theme_link')
}
export const useNodesInteractions = () => {
const { t } = useTranslation()
const { data: appDslVersion } = useSuspenseQuery({
@ -474,10 +480,12 @@ export const useNodesInteractions = () => {
)
const handleNodeClick = useCallback<NodeMouseHandler>(
(_, node) => {
(event, node) => {
const { controlMode } = workflowStore.getState()
if (controlMode === ControlMode.Comment)
return
if (isNoteLinkClickTarget(event.target, node))
return
if (node.type === CUSTOM_ITERATION_START_NODE)
return
if (node.type === CUSTOM_LOOP_START_NODE)

View File

@ -1,4 +1,5 @@
import { act, renderHook } from '@testing-library/react'
import { NOTE_SHOW_AUTHOR_STORAGE_KEY } from '../constants'
import { useNote } from '../hooks'
const mockHandleNodeDataUpdateWithSyncDraft = vi.hoisted(() => vi.fn())
@ -19,6 +20,7 @@ vi.mock('../../hooks', () => ({
describe('useNote', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
})
it('updates theme and author visibility while saving note history entries', () => {
@ -39,6 +41,7 @@ describe('useNote', () => {
})
expect(mockSaveStateToHistory).toHaveBeenNthCalledWith(1, 'note-change', { nodeId: 'note-1' })
expect(mockSaveStateToHistory).toHaveBeenNthCalledWith(2, 'note-change', { nodeId: 'note-1' })
expect(localStorage.getItem(NOTE_SHOW_AUTHOR_STORAGE_KEY)).toBe('true')
})
it('serializes non-empty editor state and clears empty editor state', () => {

View File

@ -1,6 +1,7 @@
import { NoteTheme } from './types'
export const CUSTOM_NOTE_NODE = 'custom-note'
export const NOTE_SHOW_AUTHOR_STORAGE_KEY = 'workflow-note-show-author'
export const THEME_MAP: Record<string, { outer: string, title: string, bg: string, border: string }> = {
[NoteTheme.blue]: {

View File

@ -2,6 +2,7 @@ import type { EditorState } from 'lexical'
import type { NoteTheme } from './types'
import { useCallback } from 'react'
import { useNodeDataUpdate, useWorkflowHistory, WorkflowHistoryEvent } from '../hooks'
import { NOTE_SHOW_AUTHOR_STORAGE_KEY } from './constants'
export const useNote = (id: string) => {
const { handleNodeDataUpdateWithSyncDraft } = useNodeDataUpdate()
@ -20,6 +21,7 @@ export const useNote = (id: string) => {
}, [handleNodeDataUpdateWithSyncDraft, id])
const handleShowAuthorChange = useCallback((showAuthor: boolean) => {
localStorage.setItem(NOTE_SHOW_AUTHOR_STORAGE_KEY, String(showAuthor))
handleNodeDataUpdateWithSyncDraft({ id, data: { showAuthor } })
saveStateToHistory(WorkflowHistoryEvent.NoteChange, { nodeId: id })
}, [handleNodeDataUpdateWithSyncDraft, id, saveStateToHistory])

View File

@ -1,4 +1,7 @@
import type { EditorState, LexicalEditor } from 'lexical'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { $createLinkNode } from '@lexical/link'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { $createParagraphNode, $createTextNode, $getRoot } from 'lexical'
@ -7,6 +10,10 @@ import { NoteEditorContextProvider } from '../context'
import Editor from '../editor'
const emptyValue = JSON.stringify({ root: { children: [] } })
const themeCss = readFileSync(
resolve(process.cwd(), 'app/components/workflow/note-node/note-editor/theme/theme.css'),
'utf8',
)
const EditorProbe = ({
onReady,
@ -52,6 +59,35 @@ describe('Editor', () => {
expect(screen.getByText('Type note')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should render linked text with distinct link styling', async () => {
let editor: LexicalEditor | null = null
renderEditor({}, instance => (editor = instance))
await waitFor(() => {
expect(editor).not.toBeNull()
})
act(() => {
editor!.update(() => {
const root = $getRoot()
root.clear()
const paragraph = $createParagraphNode()
const link = $createLinkNode('https://example.com/docs')
link.append($createTextNode('Linked docs'))
paragraph.append(link)
root.append(paragraph)
}, { discrete: true })
})
const link = await screen.findByRole('link', { name: 'Linked docs' })
expect(link).toHaveClass('note-editor-theme_link')
expect(themeCss).toContain('.note-editor-theme_link')
expect(themeCss).toContain('font-weight: 500;')
expect(themeCss).toContain('text-decoration: underline;')
})
})
// Focus and blur should toggle workflow shortcuts while editing content.

View File

@ -1,4 +1,4 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import NoteEditorContext from '../../../context'
import { createNoteEditorStore } from '../../../store'
import LinkEditorComponent from '../component'
@ -18,6 +18,59 @@ describe('link editor component', () => {
vi.clearAllMocks()
})
it('cancels a newly created empty link when pressing Escape', () => {
const store = createNoteEditorStore()
const anchor = document.createElement('button')
const portalRoot = document.createElement('div')
document.body.appendChild(anchor)
document.body.appendChild(portalRoot)
store.setState({
linkAnchorElement: anchor,
linkOperatorShow: false,
selectedLinkUrl: '',
})
render(
<NoteEditorContext.Provider value={store}>
<LinkEditorComponent containerElement={portalRoot} />
</NoteEditorContext.Provider>,
)
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Escape' })
expect(mockHandleUnlink).toHaveBeenCalledTimes(1)
expect(mockHandleSaveLink).not.toHaveBeenCalled()
})
it('cancels a newly created empty link when clicking outside the editor', async () => {
const store = createNoteEditorStore()
const anchor = document.createElement('button')
const portalRoot = document.createElement('div')
document.body.appendChild(anchor)
document.body.appendChild(portalRoot)
store.setState({
linkAnchorElement: anchor,
linkOperatorShow: false,
selectedLinkUrl: '',
})
render(
<NoteEditorContext.Provider value={store}>
<LinkEditorComponent containerElement={portalRoot} />
</NoteEditorContext.Provider>,
)
expect(screen.getByRole('textbox')).toBeInTheDocument()
fireEvent.mouseDown(document.body)
fireEvent.mouseUp(document.body)
fireEvent.click(document.body)
await waitFor(() => {
expect(mockHandleUnlink).toHaveBeenCalledTimes(1)
})
expect(mockHandleSaveLink).not.toHaveBeenCalled()
})
it('renders the inline link editor and saves the edited url', () => {
const store = createNoteEditorStore()
const anchor = document.createElement('button')
@ -42,4 +95,27 @@ describe('link editor component', () => {
expect(mockHandleSaveLink).toHaveBeenCalledWith('https://example.com')
})
it('saves the edited url when pressing Enter', () => {
const store = createNoteEditorStore()
const anchor = document.createElement('button')
const portalRoot = document.createElement('div')
document.body.appendChild(anchor)
document.body.appendChild(portalRoot)
store.setState({
linkAnchorElement: anchor,
linkOperatorShow: false,
selectedLinkUrl: 'https://example.com',
})
render(
<NoteEditorContext.Provider value={store}>
<LinkEditorComponent containerElement={portalRoot} />
</NoteEditorContext.Provider>,
)
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' })
expect(mockHandleSaveLink).toHaveBeenCalledWith('https://example.com')
})
})

View File

@ -14,7 +14,7 @@ const {
} = vi.hoisted(() => {
const listeners: {
update?: () => void
click?: (payload: { metaKey?: boolean, ctrlKey?: boolean }) => boolean
click?: (payload: { metaKey?: boolean, ctrlKey?: boolean, target?: EventTarget | null }) => boolean
} = {}
const editor = {
@ -36,6 +36,8 @@ const {
selectedLinkUrl: '',
setLinkAnchorElement: vi.fn(),
setLinkOperatorShow: vi.fn(),
setSelectedLinkUrl: vi.fn(),
setSelectedIsLink: vi.fn(),
},
mockListeners: listeners,
}
@ -78,6 +80,8 @@ describe('link editor hooks', () => {
mockStoreState.selectedLinkUrl = ''
mockStoreState.setLinkAnchorElement = mockSetLinkAnchorElement
mockStoreState.setLinkOperatorShow = mockSetLinkOperatorShow
mockStoreState.setSelectedLinkUrl = vi.fn()
mockStoreState.setSelectedIsLink = vi.fn()
mockListeners.update = undefined
mockListeners.click = undefined
@ -124,6 +128,26 @@ describe('link editor hooks', () => {
expect(mockSetLinkOperatorShow).toHaveBeenCalledWith(false)
})
it('should show the link operator immediately when clicking a link target', () => {
const target = document.createElement('a')
target.className = 'note-editor-theme_link'
target.href = 'https://dify.ai/docs'
renderHook(() => useOpenLink())
let handled = false
act(() => {
handled = mockListeners.click?.({ target }) ?? false
vi.runAllTimers()
})
expect(handled).toBe(true)
expect(mockStoreState.setSelectedLinkUrl).toHaveBeenCalledWith('https://dify.ai/docs')
expect(mockStoreState.setSelectedIsLink).toHaveBeenCalledWith(true)
expect(mockSetLinkAnchorElement).toHaveBeenCalledWith(target)
expect(mockSetLinkOperatorShow).toHaveBeenCalledWith(true)
})
it('should open the selected link in a new tab on meta or ctrl click', () => {
mockStoreState.selectedIsLink = true
mockStoreState.selectedLinkUrl = 'https://dify.ai'

View File

@ -16,7 +16,9 @@ import { useClickAway } from 'ahooks'
import { escape } from 'es-toolkit/string'
import {
memo,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
@ -40,6 +42,7 @@ const LinkEditorComponent = ({
const setLinkAnchorElement = useStore(s => s.setLinkAnchorElement)
const setLinkOperatorShow = useStore(s => s.setLinkOperatorShow)
const [url, setUrl] = useState(selectedLinkUrl)
const floatingRef = useRef<HTMLDivElement | null>(null)
const { refs, floatingStyles, elements } = useFloating({
placement: 'top',
middleware: [
@ -49,9 +52,19 @@ const LinkEditorComponent = ({
],
})
useClickAway(() => {
const handleCancelLinkEdit = useCallback(() => {
if (!linkOperatorShow && !selectedLinkUrl) {
handleUnlink()
return
}
setLinkAnchorElement()
}, linkAnchorElement)
setLinkOperatorShow(false)
}, [handleUnlink, linkOperatorShow, selectedLinkUrl, setLinkAnchorElement, setLinkOperatorShow])
useClickAway(() => {
handleCancelLinkEdit()
}, [floatingRef, linkAnchorElement])
useEffect(() => {
setUrl(selectedLinkUrl)
@ -74,7 +87,10 @@ const LinkEditorComponent = ({
linkOperatorShow && 'p-0.5 system-xs-medium text-text-tertiary shadow-sm',
)}
style={floatingStyles}
ref={refs.setFloating}
ref={(node) => {
refs.setFloating(node)
floatingRef.current = node
}}
>
{
!linkOperatorShow && (
@ -83,6 +99,21 @@ const LinkEditorComponent = ({
className="mr-0.5 h-6 w-[196px] appearance-none rounded-xs bg-transparent p-1 text-[13px] text-components-input-text-filled outline-hidden"
value={url}
onChange={e => setUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
e.stopPropagation()
if (url)
handleSaveLink(url)
return
}
if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
handleCancelLinkEdit()
}
}}
placeholder={t('nodes.note.editor.enterUrl', { ns: 'workflow' }) || ''}
autoFocus
/>

View File

@ -9,6 +9,12 @@ import { useTranslation } from 'react-i18next'
import { useNoteEditorStore } from '../../store'
import { urlRegExp } from '../../utils'
const getClickedLinkElement = (target: EventTarget | null) => {
return target instanceof HTMLElement
? target.closest('.note-editor-theme_link') as HTMLElement | null
: null
}
export const useOpenLink = () => {
const [editor] = useLexicalComposerContext()
const noteEditorStore = useNoteEditorStore()
@ -30,11 +36,34 @@ export const useOpenLink = () => {
})
}), editor.registerCommand(CLICK_COMMAND, (payload) => {
setTimeout(() => {
const { selectedLinkUrl, selectedIsLink, setLinkAnchorElement, setLinkOperatorShow } = noteEditorStore.getState()
const {
selectedLinkUrl,
selectedIsLink,
setLinkAnchorElement,
setLinkOperatorShow,
setSelectedLinkUrl,
setSelectedIsLink,
} = noteEditorStore.getState()
const clickedLinkElement = getClickedLinkElement(payload.target)
const clickedLinkUrl = clickedLinkElement?.getAttribute('href') || selectedLinkUrl
if (clickedLinkElement && clickedLinkUrl) {
if (payload.metaKey || payload.ctrlKey) {
window.open(clickedLinkUrl, '_blank')
return
}
setSelectedLinkUrl(clickedLinkUrl)
setSelectedIsLink(true)
setLinkAnchorElement(clickedLinkElement)
setLinkOperatorShow(true)
return
}
if (selectedIsLink) {
if ((payload.metaKey || payload.ctrlKey) && selectedLinkUrl) {
window.open(selectedLinkUrl, '_blank')
return true
return
}
setLinkAnchorElement(true)
if (selectedLinkUrl)
@ -47,7 +76,7 @@ export const useOpenLink = () => {
setLinkOperatorShow(false)
}
})
return false
return !!getClickedLinkElement(payload.target)
}, COMMAND_PRIORITY_LOW))
}, [editor, noteEditorStore])
}

View File

@ -7,7 +7,7 @@ import NoteEditorContext from './context'
type Shape = {
linkAnchorElement: HTMLElement | null
setLinkAnchorElement: (open?: boolean) => void
setLinkAnchorElement: (open?: boolean | HTMLElement | null) => void
linkOperatorShow: boolean
setLinkOperatorShow: (linkOperatorShow: boolean) => void
selectedIsBold: boolean
@ -28,6 +28,11 @@ export const createNoteEditorStore = () => {
return createStore<Shape>(set => ({
linkAnchorElement: null,
setLinkAnchorElement: (open) => {
if (open instanceof HTMLElement) {
set(() => ({ linkAnchorElement: open }))
return
}
if (open) {
setTimeout(() => {
const nativeSelection = window.getSelection()

View File

@ -8,7 +8,7 @@ const theme: EditorThemeClasses = {
ul: 'note-editor-theme_list-ul',
listitem: 'note-editor-theme_list-li',
},
link: 'note-editor-theme_link',
link: 'note-editor-theme_link nodrag nopan nowheel',
text: {
italic: 'note-editor-theme_text-italic',
strikethrough: 'note-editor-theme_text-strikethrough',

View File

@ -16,11 +16,18 @@
.note-editor-theme_link {
cursor: pointer;
color: var(--text-text-selected);
color: var(--color-text-accent);
font-weight: 500;
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
text-decoration-color: color-mix(in srgb, var(--color-text-accent) 60%, transparent);
transition: color 0.15s ease, text-decoration-color 0.15s ease;
}
.note-editor-theme_link:hover {
text-decoration: underline;
color: var(--color-text-accent-secondary);
text-decoration-color: currentColor;
}
.note-editor-theme_text-strikethrough {

View File

@ -1,7 +1,10 @@
import type { NoteNodeType } from '../note-node/types'
import { useCallback } from 'react'
import { useAppContext } from '@/context/app-context'
import { CUSTOM_NOTE_NODE } from '../note-node/constants'
import {
CUSTOM_NOTE_NODE,
NOTE_SHOW_AUTHOR_STORAGE_KEY,
} from '../note-node/constants'
import { NoteTheme } from '../note-node/types'
import { useWorkflowStore } from '../store'
import { generateNewNode } from '../utils'
@ -20,7 +23,7 @@ export const useOperator = () => {
text: '',
theme: NoteTheme.blue,
author: userProfile?.name || '',
showAuthor: true,
showAuthor: localStorage.getItem(NOTE_SHOW_AUTHOR_STORAGE_KEY) !== 'false',
width: 240,
height: 88,
_isCandidate: true,