diff --git a/web/app/components/app-sidebar/index.tsx b/web/app/components/app-sidebar/index.tsx index 716b73abf5..4750d7919c 100644 --- a/web/app/components/app-sidebar/index.tsx +++ b/web/app/components/app-sidebar/index.tsx @@ -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 }) diff --git a/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts b/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts index 41d1fb39d9..a77606fefc 100644 --- a/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts @@ -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({ diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index 0ce86602fa..beb3f3733d 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -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( - (_, 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) diff --git a/web/app/components/workflow/note-node/__tests__/hooks.spec.tsx b/web/app/components/workflow/note-node/__tests__/hooks.spec.tsx index 9642d3d9bf..f31e550284 100644 --- a/web/app/components/workflow/note-node/__tests__/hooks.spec.tsx +++ b/web/app/components/workflow/note-node/__tests__/hooks.spec.tsx @@ -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', () => { diff --git a/web/app/components/workflow/note-node/constants.ts b/web/app/components/workflow/note-node/constants.ts index b2fa223690..d4891977cc 100644 --- a/web/app/components/workflow/note-node/constants.ts +++ b/web/app/components/workflow/note-node/constants.ts @@ -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 = { [NoteTheme.blue]: { diff --git a/web/app/components/workflow/note-node/hooks.ts b/web/app/components/workflow/note-node/hooks.ts index 6924f31af5..6248e7670d 100644 --- a/web/app/components/workflow/note-node/hooks.ts +++ b/web/app/components/workflow/note-node/hooks.ts @@ -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]) diff --git a/web/app/components/workflow/note-node/note-editor/__tests__/editor.spec.tsx b/web/app/components/workflow/note-node/note-editor/__tests__/editor.spec.tsx index 92df65d8f2..b675f57849 100644 --- a/web/app/components/workflow/note-node/note-editor/__tests__/editor.spec.tsx +++ b/web/app/components/workflow/note-node/note-editor/__tests__/editor.spec.tsx @@ -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. diff --git a/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/__tests__/component.spec.tsx b/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/__tests__/component.spec.tsx index b288421b60..2a278fc1e0 100644 --- a/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/__tests__/component.spec.tsx +++ b/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/__tests__/component.spec.tsx @@ -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( + + + , + ) + + 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( + + + , + ) + + 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( + + + , + ) + + fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' }) + + expect(mockHandleSaveLink).toHaveBeenCalledWith('https://example.com') + }) }) diff --git a/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/__tests__/hooks.spec.tsx b/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/__tests__/hooks.spec.tsx index 4272050fac..c0c6767b46 100644 --- a/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/__tests__/hooks.spec.tsx +++ b/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/__tests__/hooks.spec.tsx @@ -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' diff --git a/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/component.tsx b/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/component.tsx index 754905cd46..7e5b6c586c 100644 --- a/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/component.tsx +++ b/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/component.tsx @@ -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(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 /> diff --git a/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/hooks.ts b/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/hooks.ts index 6debfa5f8b..361a74f4c6 100644 --- a/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/hooks.ts +++ b/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/hooks.ts @@ -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]) } diff --git a/web/app/components/workflow/note-node/note-editor/store.ts b/web/app/components/workflow/note-node/note-editor/store.ts index 3507bb7c0c..4bba12b6f6 100644 --- a/web/app/components/workflow/note-node/note-editor/store.ts +++ b/web/app/components/workflow/note-node/note-editor/store.ts @@ -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(set => ({ linkAnchorElement: null, setLinkAnchorElement: (open) => { + if (open instanceof HTMLElement) { + set(() => ({ linkAnchorElement: open })) + return + } + if (open) { setTimeout(() => { const nativeSelection = window.getSelection() diff --git a/web/app/components/workflow/note-node/note-editor/theme/index.ts b/web/app/components/workflow/note-node/note-editor/theme/index.ts index 5cb8dec37f..a815291d05 100644 --- a/web/app/components/workflow/note-node/note-editor/theme/index.ts +++ b/web/app/components/workflow/note-node/note-editor/theme/index.ts @@ -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', diff --git a/web/app/components/workflow/note-node/note-editor/theme/theme.css b/web/app/components/workflow/note-node/note-editor/theme/theme.css index 77b745ca4a..6d22c58b1c 100644 --- a/web/app/components/workflow/note-node/note-editor/theme/theme.css +++ b/web/app/components/workflow/note-node/note-editor/theme/theme.css @@ -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 { diff --git a/web/app/components/workflow/operator/hooks.ts b/web/app/components/workflow/operator/hooks.ts index 23248a89a3..dcdfa4f629 100644 --- a/web/app/components/workflow/operator/hooks.ts +++ b/web/app/components/workflow/operator/hooks.ts @@ -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,