diff --git a/package.json b/package.json index 7b8a1c44c7..684aa36bf2 100644 --- a/package.json +++ b/package.json @@ -229,7 +229,9 @@ "fs-extra": "^11.2.0", "google-auth-library": "^9.15.1", "he": "^1.2.0", + "html-tags": "^5.1.0", "html-to-image": "^1.11.13", + "htmlparser2": "^10.0.0", "husky": "^9.1.7", "i18next": "^23.11.5", "iconv-lite": "^0.6.3", diff --git a/src/renderer/src/assets/styles/richtext.scss b/src/renderer/src/assets/styles/richtext.scss index 3890f013ec..7b4e4bdad5 100644 --- a/src/renderer/src/assets/styles/richtext.scss +++ b/src/renderer/src/assets/styles/richtext.scss @@ -148,6 +148,12 @@ left: 0; right: 0; } + + /* Ensure drag handles and plus buttons remain interactive */ + .drag-handle, + .plus-button { + pointer-events: auto; + } } /* Show placeholder only when focused or when it's the only empty node */ @@ -471,6 +477,14 @@ align-items: center; font-size: 1.2rem; } + + /* Bottom spacer to create viewport padding */ + &::after { + content: ''; + display: block; + height: 50px; + pointer-events: none; + } } // Code block wrapper and header styles diff --git a/src/renderer/src/components/CodeViewer.tsx b/src/renderer/src/components/CodeViewer.tsx index 87a33534b9..440aed9c7c 100644 --- a/src/renderer/src/components/CodeViewer.tsx +++ b/src/renderer/src/components/CodeViewer.tsx @@ -17,6 +17,7 @@ interface CodeViewerProps { wrapped?: boolean onHeightChange?: (scrollHeight: number) => void className?: string + height?: string | number } /** @@ -25,7 +26,7 @@ interface CodeViewerProps { * - 使用虚拟滚动和按需高亮,改善页面内有大量长代码块时的响应 * - 并发安全 */ -const CodeViewer = ({ children, language, expanded, wrapped, onHeightChange, className }: CodeViewerProps) => { +const CodeViewer = ({ children, language, expanded, wrapped, onHeightChange, className, height }: CodeViewerProps) => { const { codeShowLineNumbers, fontSize } = useSettings() const { getShikiPreProperties, isShikiThemeDark } = useCodeStyle() const shikiThemeRef = useRef(null) @@ -104,18 +105,20 @@ const CodeViewer = ({ children, language, expanded, wrapped, onHeightChange, cla }, [rawLines.length, onHeightChange]) return ( -
+
@@ -225,6 +228,7 @@ const ScrollContainer = styled.div<{ $wrap?: boolean $expanded?: boolean $lineHeight?: number + $height?: string | number }>` display: block; overflow-x: auto; diff --git a/src/renderer/src/components/RichEditor/styles.ts b/src/renderer/src/components/RichEditor/styles.ts index 2a1cfa8708..8c4bc608cb 100644 --- a/src/renderer/src/components/RichEditor/styles.ts +++ b/src/renderer/src/components/RichEditor/styles.ts @@ -61,15 +61,13 @@ export const ToolbarButton = styled.button<{ height: 32px; border: none; border-radius: 4px; - background: ${({ $active }) => ($active ? 'var(--color-primary)' : 'transparent')}; - color: ${({ $active, $disabled }) => - $disabled ? 'var(--color-text-3)' : $active ? 'var(--color-white)' : 'var(--color-text)'}; + background: transparent; cursor: ${({ $disabled }) => ($disabled ? 'not-allowed' : 'pointer')}; transition: all 0.2s ease; flex-shrink: 0; /* 防止按钮收缩 */ &:hover:not(:disabled) { - background: ${({ $active }) => ($active ? 'var(--color-primary)' : 'var(--color-hover)')}; + background: var(--color-hover); } &:disabled { diff --git a/src/renderer/src/components/RichEditor/toolbar.tsx b/src/renderer/src/components/RichEditor/toolbar.tsx index a7d25efac6..ebed37349e 100644 --- a/src/renderer/src/components/RichEditor/toolbar.tsx +++ b/src/renderer/src/components/RichEditor/toolbar.tsx @@ -1,6 +1,7 @@ import { Tooltip } from 'antd' import type { TFunction } from 'i18next' -import React, { useEffect, useState } from 'react' +import { LucideProps } from 'lucide-react' +import React, { ForwardRefExoticComponent, RefAttributes, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { getCommandsByGroup } from './command' @@ -12,7 +13,7 @@ import type { FormattingCommand, FormattingState, ToolbarProps } from './types' interface ToolbarItemInternal { id: string command?: FormattingCommand - icon?: React.ComponentType + icon?: ForwardRefExoticComponent & RefAttributes> type?: 'divider' handler?: () => void } @@ -170,7 +171,7 @@ export const Toolbar: React.FC = ({ editor, formattingState, onCom disabled={isDisabled} onClick={() => handleCommand(command)} data-testid={`toolbar-${command}`}> - + ) diff --git a/src/renderer/src/components/RichEditor/useRichEditor.ts b/src/renderer/src/components/RichEditor/useRichEditor.ts index d68841b0a4..8275d3623d 100644 --- a/src/renderer/src/components/RichEditor/useRichEditor.ts +++ b/src/renderer/src/components/RichEditor/useRichEditor.ts @@ -8,9 +8,7 @@ import { htmlToMarkdown, isMarkdownContent, markdownToHtml, - markdownToPreviewText, - markdownToSafeHtml, - sanitizeHtml + markdownToPreviewText } from '@renderer/utils/markdownConverter' import type { Editor } from '@tiptap/core' import { TaskItem, TaskList } from '@tiptap/extension-list' @@ -135,7 +133,7 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor const html = useMemo(() => { if (!markdown) return '' - return markdownToSafeHtml(markdown) + return markdownToHtml(markdown) }, [markdown]) const previewText = useMemo(() => { @@ -423,8 +421,7 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor onContentChange?.(content) if (onHtmlChange) { - const safeHtml = sanitizeHtml(htmlContent) - onHtmlChange(safeHtml) + onHtmlChange(htmlContent) } } catch (error) { logger.error('Error converting HTML to markdown:', error as Error) @@ -502,7 +499,10 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor try { setTimeout(() => { if (editor && !editor.isDestroyed) { - editor.commands.focus('end') + const isLong = editor.getText().length > 2000 + if (!isLong) { + editor.commands.focus('end') + } } }, 0) } catch (error) { @@ -724,7 +724,7 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor setMarkdownState(content) onChange?.(content) - const convertedHtml = markdownToSafeHtml(content) + const convertedHtml = markdownToHtml(content) editor.commands.setContent(convertedHtml) @@ -771,7 +771,7 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor const toSafeHtml = useCallback((content: string): string => { try { - return markdownToSafeHtml(content) + return markdownToHtml(content) } catch (error) { logger.error('Error converting markdown to safe HTML:', error as Error) return '' diff --git a/src/renderer/src/pages/notes/HeaderNavbar.tsx b/src/renderer/src/pages/notes/HeaderNavbar.tsx index 1994ae6a93..8e54b08ab4 100644 --- a/src/renderer/src/pages/notes/HeaderNavbar.tsx +++ b/src/renderer/src/pages/notes/HeaderNavbar.tsx @@ -109,7 +109,9 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent }) => { }, [activeNode, notesTree]) return ( - + {showWorkspace && ( diff --git a/src/renderer/src/pages/notes/NotesEditor.tsx b/src/renderer/src/pages/notes/NotesEditor.tsx index e181042fc6..affc5b7942 100644 --- a/src/renderer/src/pages/notes/NotesEditor.tsx +++ b/src/renderer/src/pages/notes/NotesEditor.tsx @@ -145,6 +145,7 @@ const RichEditorContainer = styled.div` .notes-rich-editor { border: none; + border-radius: 0; flex: 1; background: transparent; @@ -173,7 +174,7 @@ const RichEditorContainer = styled.div` const BottomPanel = styled.div` padding: 8px 16px; - border-top: 1px solid var(--color-border); + border-top: 0.5px solid var(--color-border); background: var(--color-background-soft); flex-shrink: 0; height: 48px; diff --git a/src/renderer/src/pages/notes/NotesPage.tsx b/src/renderer/src/pages/notes/NotesPage.tsx index eb0e627323..208cc92345 100644 --- a/src/renderer/src/pages/notes/NotesPage.tsx +++ b/src/renderer/src/pages/notes/NotesPage.tsx @@ -53,6 +53,7 @@ const NotesPage: FC = () => { const isSyncingTreeRef = useRef(false) const isEditorInitialized = useRef(false) const lastContentRef = useRef('') + const lastFilePathRef = useRef(undefined) const isInitialSortApplied = useRef(false) const isRenamingRef = useRef(false) const isCreatingNoteRef = useRef(false) @@ -82,13 +83,14 @@ const NotesPage: FC = () => { // 保存当前笔记内容 const saveCurrentNote = useCallback( - async (content: string) => { - if (!activeFilePath || content === currentContent) return + async (content: string, filePath?: string) => { + const targetPath = filePath || activeFilePath + if (!targetPath || content === currentContent) return try { - await window.api.file.write(activeFilePath, content) + await window.api.file.write(targetPath, content) // 保存后立即刷新缓存,确保下次读取时获取最新内容 - invalidateFileContent(activeFilePath) + invalidateFileContent(targetPath) } catch (error) { logger.error('Failed to save note:', error as Error) } @@ -99,19 +101,22 @@ const NotesPage: FC = () => { // 防抖保存函数,在停止输入后才保存,避免输入过程中的文件写入 const debouncedSave = useMemo( () => - debounce((content: string) => { - saveCurrentNote(content) + debounce((content: string, filePath: string | undefined) => { + saveCurrentNote(content, filePath) }, 800), // 800ms防抖延迟 [saveCurrentNote] ) const handleMarkdownChange = useCallback( (newMarkdown: string) => { - // 记录最新内容,用于兜底保存 + // 记录最新内容和文件路径,用于兜底保存 lastContentRef.current = newMarkdown - debouncedSave(newMarkdown) + lastFilePathRef.current = activeFilePath + // 捕获当前文件路径,避免在防抖执行时文件路径已改变的竞态条件 + const currentFilePath = activeFilePath + debouncedSave(newMarkdown, currentFilePath) }, - [debouncedSave] + [debouncedSave, activeFilePath] ) useEffect(() => { @@ -148,16 +153,16 @@ const NotesPage: FC = () => { // 处理树同步时的状态管理 useEffect(() => { if (notesTree.length === 0) return - // 如果有activeFilePath但找不到对应节点,清空选择 // 但要排除正在同步树结构、重命名或创建笔记的情况,避免在这些操作中误清空 - if ( - activeFilePath && - !activeNode && - !isSyncingTreeRef.current && - !isRenamingRef.current && - !isCreatingNoteRef.current - ) { + const shouldClearPath = + activeFilePath && !activeNode && !isSyncingTreeRef.current && !isRenamingRef.current && !isCreatingNoteRef.current + + if (shouldClearPath) { + logger.warn('Clearing activeFilePath - node not found in tree', { + activeFilePath, + reason: 'Node not found in current tree' + }) dispatch(setActiveFilePath(undefined)) } }, [notesTree, activeFilePath, activeNode, dispatch]) @@ -257,8 +262,8 @@ const NotesPage: FC = () => { }) // 如果有未保存的内容,立即保存 - if (lastContentRef.current && lastContentRef.current !== currentContent) { - saveCurrentNote(lastContentRef.current).catch((error) => { + if (lastContentRef.current && lastContentRef.current !== currentContent && lastFilePathRef.current) { + saveCurrentNote(lastContentRef.current, lastFilePathRef.current).catch((error) => { logger.error('Emergency save failed:', error as Error) }) } @@ -288,8 +293,8 @@ const NotesPage: FC = () => { // 切换文件时重置编辑器初始化状态并兜底保存 useEffect(() => { - if (lastContentRef.current && lastContentRef.current !== currentContent) { - saveCurrentNote(lastContentRef.current).catch((error) => { + if (lastContentRef.current && lastContentRef.current !== currentContent && lastFilePathRef.current) { + saveCurrentNote(lastContentRef.current, lastFilePathRef.current).catch((error) => { logger.error('Emergency save before file switch failed:', error as Error) }) } @@ -297,6 +302,7 @@ const NotesPage: FC = () => { // 重置状态 isEditorInitialized.current = false lastContentRef.current = '' + lastFilePathRef.current = undefined }, [activeFilePath, currentContent, saveCurrentNote]) // 获取目标文件夹路径(选中文件夹或根目录) @@ -425,6 +431,7 @@ const NotesPage: FC = () => { if (node.type === 'file') { try { dispatch(setActiveFilePath(node.externalPath)) + invalidateFileContent(node.externalPath) // 清除文件夹选择状态 setSelectedFolderId(null) } catch (error) { @@ -435,7 +442,7 @@ const NotesPage: FC = () => { await handleToggleExpanded(node.id) } }, - [dispatch, handleToggleExpanded] + [dispatch, handleToggleExpanded, invalidateFileContent] ) // 删除节点 diff --git a/src/renderer/src/pages/notes/NotesSidebar.tsx b/src/renderer/src/pages/notes/NotesSidebar.tsx index 9400ad37cf..05fc76c079 100644 --- a/src/renderer/src/pages/notes/NotesSidebar.tsx +++ b/src/renderer/src/pages/notes/NotesSidebar.tsx @@ -522,7 +522,7 @@ const SidebarContainer = styled.div` width: 250px; height: 100vh; background-color: var(--color-background); - border-right: 1px solid var(--color-border); + border-right: 0.5px solid var(--color-border); border-top-left-radius: 10px; display: flex; flex-direction: column; @@ -568,7 +568,7 @@ const TreeNodeContainer = styled.div<{ if (props.active) return 'var(--color-background-soft)' return 'transparent' }}; - border: 1px solid + border: 0.5px solid ${(props) => { if (props.isDragInside) return 'var(--color-primary)' if (props.active) return 'var(--color-border)' @@ -669,7 +669,7 @@ const EditInput = styled(Input)` .ant-input { font-size: 13px; padding: 2px 6px; - border: 1px solid var(--color-primary); + border: 0.5px solid var(--color-primary); } ` diff --git a/src/renderer/src/pages/notes/NotesSidebarHeader.tsx b/src/renderer/src/pages/notes/NotesSidebarHeader.tsx index 03eca39bdf..7eba3fe3c2 100644 --- a/src/renderer/src/pages/notes/NotesSidebarHeader.tsx +++ b/src/renderer/src/pages/notes/NotesSidebarHeader.tsx @@ -138,9 +138,10 @@ const NotesSidebarHeader: FC = ({ const SidebarHeader = styled.div<{ isStarView?: boolean; isSearchView?: boolean }>` padding: 8px 12px; - border-bottom: 1px solid var(--color-border); + border-bottom: 0.5px solid var(--color-border); display: flex; justify-content: ${(props) => (props.isStarView || props.isSearchView ? 'flex-start' : 'center')}; + height: var(--navbar-height); ` const HeaderActions = styled.div` diff --git a/src/renderer/src/pages/settings/AssistantSettings/AssistantPromptSettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantPromptSettings.tsx index 034ba918c7..9f27db6afb 100644 --- a/src/renderer/src/pages/settings/AssistantSettings/AssistantPromptSettings.tsx +++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantPromptSettings.tsx @@ -1,9 +1,10 @@ import 'emoji-picker-element' import { CloseCircleFilled } from '@ant-design/icons' +import CodeEditor from '@renderer/components/CodeEditor' +import CodeViewer from '@renderer/components/CodeViewer' import EmojiPicker from '@renderer/components/EmojiPicker' import { Box, HSpaceBetweenStack, HStack } from '@renderer/components/Layout' -import RichEditor from '@renderer/components/RichEditor' import { RichEditorRef } from '@renderer/components/RichEditor/types' import { usePromptProcessor } from '@renderer/hooks/usePromptProcessor' import { estimateTextTokens } from '@renderer/services/TokenService' @@ -47,9 +48,7 @@ const AssistantPromptSettings: React.FC = ({ assistant, updateAssistant } }) const onUpdate = () => { - const text = editorRef.current?.getMarkdown() || '' - setPrompt(text) - const _assistant = { ...assistant, name: name.trim(), emoji, prompt: text } + const _assistant = { ...assistant, name: name.trim(), emoji, prompt } updateAssistant(_assistant) window.message.success(t('common.saved')) } @@ -68,13 +67,6 @@ const AssistantPromptSettings: React.FC = ({ assistant, updateAssistant } const promptVarsContent =
{t('agents.add.prompt.variables.tip.content')}
- const handleCommandsReady = (commandAPI: Pick) => { - const disabledCommands = ['image', 'inlineMath'] - disabledCommands.forEach((commandId) => { - commandAPI.unregisterCommand(commandId) - }) - } - return ( @@ -129,18 +121,20 @@ const AssistantPromptSettings: React.FC = ({ assistant, updateAssistant }
- + {showPreview ? ( + + ) : ( + + )} diff --git a/src/renderer/src/utils/__tests__/markdownConverter.test.ts b/src/renderer/src/utils/__tests__/markdownConverter.test.ts index 9f42f61830..2224f7f0e7 100644 --- a/src/renderer/src/utils/__tests__/markdownConverter.test.ts +++ b/src/renderer/src/utils/__tests__/markdownConverter.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { htmlToMarkdown, markdownToHtml, markdownToSafeHtml, sanitizeHtml } from '../markdownConverter' +import { htmlToMarkdown, markdownToHtml } from '../markdownConverter' describe('markdownConverter', () => { describe('htmlToMarkdown', () => { @@ -294,33 +294,6 @@ describe('markdownConverter', () => { }) }) - describe('sanitizeHtml', () => { - it('should sanitize HTML content and remove scripts', () => { - const html = '

Hello

' - const result = sanitizeHtml(html) - expect(result).toContain('

Hello

') - expect(result).not.toContain('