Merge remote-tracking branch 'origin/main' into feat/support-agent-sandbox

This commit is contained in:
yyh
2026-03-24 19:30:56 +08:00
95 changed files with 10275 additions and 2761 deletions

View File

@ -0,0 +1,209 @@
import { renderHook } from '@testing-library/react'
import { useCommand, useFontSize } from '../hooks'
type MockSelectionParent = { isLink: boolean } | null
const {
mockDispatchCommand,
mockEditorUpdate,
mockRegisterUpdateListener,
mockRegisterCommand,
mockRead,
mockSetLinkAnchorElement,
mockSelectionNode,
mockSelection,
mockPatchStyleText,
mockSetSelection,
mockSelectionFontSize,
mockIsRangeSelection,
mockSelectedIsBullet,
mockSetBlocksType,
} = vi.hoisted(() => ({
mockDispatchCommand: vi.fn(),
mockEditorUpdate: vi.fn(),
mockRegisterUpdateListener: vi.fn(),
mockRegisterCommand: vi.fn(),
mockRead: vi.fn(),
mockSetLinkAnchorElement: vi.fn(),
mockSelectionNode: {
getParent: vi.fn<() => MockSelectionParent>(() => null),
},
mockSelection: {
anchor: {
getNode: vi.fn(),
},
focus: {
getNode: vi.fn(),
},
isBackward: vi.fn(() => false),
clone: vi.fn(() => 'cloned-selection'),
},
mockPatchStyleText: vi.fn(),
mockSetSelection: vi.fn(),
mockSelectionFontSize: vi.fn(),
mockIsRangeSelection: vi.fn(() => true),
mockSelectedIsBullet: vi.fn(() => false),
mockSetBlocksType: vi.fn(),
}))
vi.mock('@lexical/react/LexicalComposerContext', () => ({
useLexicalComposerContext: () => ([{
dispatchCommand: mockDispatchCommand,
update: mockEditorUpdate,
registerUpdateListener: mockRegisterUpdateListener,
registerCommand: mockRegisterCommand,
getEditorState: () => ({
read: mockRead,
}),
}]),
}))
vi.mock('@lexical/link', () => ({
$isLinkNode: (node: unknown) => Boolean(node && typeof node === 'object' && 'isLink' in (node as object)),
TOGGLE_LINK_COMMAND: 'toggle-link-command',
}))
vi.mock('@lexical/list', () => ({
INSERT_UNORDERED_LIST_COMMAND: 'insert-unordered-list-command',
}))
vi.mock('@lexical/selection', () => ({
$getSelectionStyleValueForProperty: () => mockSelectionFontSize(),
$isAtNodeEnd: () => false,
$patchStyleText: mockPatchStyleText,
$setBlocksType: mockSetBlocksType,
}))
vi.mock('@lexical/utils', () => ({
mergeRegister: (...cleanups: Array<() => void>) => () => cleanups.forEach(cleanup => cleanup()),
}))
vi.mock('lexical', () => ({
$createParagraphNode: () => ({ type: 'paragraph' }),
$getSelection: () => mockSelection,
$isRangeSelection: () => mockIsRangeSelection(),
$setSelection: mockSetSelection,
COMMAND_PRIORITY_CRITICAL: 4,
FORMAT_TEXT_COMMAND: 'format-text-command',
SELECTION_CHANGE_COMMAND: 'selection-change-command',
}))
vi.mock('../../store', () => ({
useNoteEditorStore: () => ({
getState: () => ({
selectedIsBullet: mockSelectedIsBullet(),
setLinkAnchorElement: mockSetLinkAnchorElement,
}),
}),
}))
describe('note toolbar hooks', () => {
beforeEach(() => {
vi.clearAllMocks()
mockEditorUpdate.mockImplementation((callback) => {
callback()
})
mockRegisterUpdateListener.mockImplementation((listener) => {
listener({})
return vi.fn()
})
mockRegisterCommand.mockImplementation((_command, listener) => {
listener()
return vi.fn()
})
mockRead.mockImplementation((callback) => {
callback()
})
mockSelectionFontSize.mockReturnValue('16px')
mockIsRangeSelection.mockReturnValue(true)
mockSelectedIsBullet.mockReturnValue(false)
mockSelection.anchor.getNode.mockReturnValue(mockSelectionNode)
mockSelection.focus.getNode.mockReturnValue(mockSelectionNode)
mockSelectionNode.getParent.mockReturnValue(null)
})
describe('useCommand', () => {
it('should dispatch text formatting commands directly', () => {
const { result } = renderHook(() => useCommand())
result.current.handleCommand('bold')
result.current.handleCommand('italic')
result.current.handleCommand('strikethrough')
expect(mockDispatchCommand).toHaveBeenNthCalledWith(1, 'format-text-command', 'bold')
expect(mockDispatchCommand).toHaveBeenNthCalledWith(2, 'format-text-command', 'italic')
expect(mockDispatchCommand).toHaveBeenNthCalledWith(3, 'format-text-command', 'strikethrough')
})
it('should open link editing when current selection is not already a link', () => {
const { result } = renderHook(() => useCommand())
result.current.handleCommand('link')
expect(mockDispatchCommand).toHaveBeenCalledWith('toggle-link-command', '')
expect(mockSetLinkAnchorElement).toHaveBeenCalledWith(true)
})
it('should remove the link when the current selection is already within a link node', () => {
mockSelectionNode.getParent.mockReturnValue({ isLink: true })
const { result } = renderHook(() => useCommand())
result.current.handleCommand('link')
expect(mockDispatchCommand).toHaveBeenCalledWith('toggle-link-command', null)
expect(mockSetLinkAnchorElement).toHaveBeenCalledWith()
})
it('should ignore link commands when the selection is not a range', () => {
mockIsRangeSelection.mockReturnValue(false)
const { result } = renderHook(() => useCommand())
result.current.handleCommand('link')
expect(mockDispatchCommand).not.toHaveBeenCalled()
expect(mockSetLinkAnchorElement).not.toHaveBeenCalled()
})
it('should toggle bullet formatting on and off', () => {
const { result, rerender } = renderHook(() => useCommand())
result.current.handleCommand('bullet')
expect(mockDispatchCommand).toHaveBeenCalledWith('insert-unordered-list-command', undefined)
mockSelectedIsBullet.mockReturnValue(true)
rerender()
result.current.handleCommand('bullet')
expect(mockSetBlocksType).toHaveBeenCalledWith(mockSelection, expect.any(Function))
})
})
describe('useFontSize', () => {
it('should expose font size state and update selection styling', () => {
const { result } = renderHook(() => useFontSize())
expect(result.current.fontSize).toBe('16px')
result.current.handleFontSize('20px')
expect(mockPatchStyleText).toHaveBeenCalledWith(mockSelection, { 'font-size': '20px' })
})
it('should preserve the current selection when opening the selector', () => {
const { result } = renderHook(() => useFontSize())
result.current.handleOpenFontSizeSelector(true)
expect(mockSetSelection).toHaveBeenCalledWith('cloned-selection')
})
it('should keep the default font size and avoid patching styles when the selection is not a range', () => {
mockIsRangeSelection.mockReturnValue(false)
const { result } = renderHook(() => useFontSize())
expect(result.current.fontSize).toBe('12px')
result.current.handleFontSize('20px')
expect(mockPatchStyleText).not.toHaveBeenCalled()
})
})
})

View File

@ -27,55 +27,72 @@ import {
import { useNoteEditorStore } from '../store'
import { getSelectedNode } from '../utils'
const DEFAULT_FONT_SIZE = '12px'
const updateFontSizeFromSelection = (setFontSize: (fontSize: string) => void) => {
const selection = $getSelection()
if ($isRangeSelection(selection))
setFontSize($getSelectionStyleValueForProperty(selection, 'font-size', DEFAULT_FONT_SIZE))
}
const toggleLink = (
editor: ReturnType<typeof useLexicalComposerContext>[0],
noteEditorStore: ReturnType<typeof useNoteEditorStore>,
) => {
editor.update(() => {
const selection = $getSelection()
if (!$isRangeSelection(selection))
return
const node = getSelectedNode(selection)
const parent = node.getParent()
const { setLinkAnchorElement } = noteEditorStore.getState()
if ($isLinkNode(parent) || $isLinkNode(node)) {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
setLinkAnchorElement()
return
}
editor.dispatchCommand(TOGGLE_LINK_COMMAND, '')
setLinkAnchorElement(true)
})
}
const toggleBullet = (
editor: ReturnType<typeof useLexicalComposerContext>[0],
selectedIsBullet: boolean,
) => {
if (!selectedIsBullet) {
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined)
return
}
editor.update(() => {
const selection = $getSelection()
if ($isRangeSelection(selection))
$setBlocksType(selection, () => $createParagraphNode())
})
}
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 === 'bold' || type === 'italic' || type === 'strikethrough') {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, type)
return
}
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)
}
}
})
toggleLink(editor, noteEditorStore)
return
}
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)
}
}
if (type === 'bullet')
toggleBullet(editor, noteEditorStore.getState().selectedIsBullet)
}, [editor, noteEditorStore])
return {
@ -85,7 +102,7 @@ export const useCommand = () => {
export const useFontSize = () => {
const [editor] = useLexicalComposerContext()
const [fontSize, setFontSize] = useState('12px')
const [fontSize, setFontSize] = useState(DEFAULT_FONT_SIZE)
const [fontSizeSelectorShow, setFontSizeSelectorShow] = useState(false)
const handleFontSize = useCallback((fontSize: string) => {
@ -113,24 +130,13 @@ export const useFontSize = () => {
return mergeRegister(
editor.registerUpdateListener(() => {
editor.getEditorState().read(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
const fontSize = $getSelectionStyleValueForProperty(selection, 'font-size', '12px')
setFontSize(fontSize)
}
updateFontSizeFromSelection(setFontSize)
})
}),
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
const fontSize = $getSelectionStyleValueForProperty(selection, 'font-size', '12px')
setFontSize(fontSize)
}
updateFontSizeFromSelection(setFontSize)
return false
},
COMMAND_PRIORITY_CRITICAL,