diff --git a/src/renderer/src/components/CodeEditor/hooks.ts b/src/renderer/src/components/CodeEditor/hooks.ts index b6689644e9..65c18a5a0f 100644 --- a/src/renderer/src/components/CodeEditor/hooks.ts +++ b/src/renderer/src/components/CodeEditor/hooks.ts @@ -2,7 +2,7 @@ import { linter } from '@codemirror/lint' // statically imported by @uiw/codemir import { EditorView } from '@codemirror/view' import { loggerService } from '@logger' import { Extension, keymap } from '@uiw/react-codemirror' -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { getNormalizedExtension } from './utils' @@ -203,3 +203,80 @@ export function useHeightListener({ onHeightChange }: UseHeightListenerProps) { }) }, [onHeightChange]) } + +interface UseScrollToLineOptions { + highlight?: boolean +} + +export function useScrollToLine(editorViewRef: React.MutableRefObject) { + const findLineElement = useCallback((view: EditorView, position: number): HTMLElement | null => { + const domAtPos = view.domAtPos(position) + let node: Node | null = domAtPos.node + + if (node.nodeType === Node.TEXT_NODE) { + node = node.parentElement + } + + while (node) { + if (node instanceof HTMLElement && node.classList.contains('cm-line')) { + return node + } + node = node.parentElement + } + + return null + }, []) + + const highlightLine = useCallback((view: EditorView, element: HTMLElement) => { + const previousHighlight = view.dom.querySelector('.animation-locate-highlight') as HTMLElement | null + if (previousHighlight) { + previousHighlight.classList.remove('animation-locate-highlight') + } + + element.classList.add('animation-locate-highlight') + + const handleAnimationEnd = () => { + element.classList.remove('animation-locate-highlight') + element.removeEventListener('animationend', handleAnimationEnd) + } + + element.addEventListener('animationend', handleAnimationEnd) + }, []) + + return useCallback( + (lineNumber: number, options?: UseScrollToLineOptions) => { + const view = editorViewRef.current + if (!view) return + + const targetLine = view.state.doc.line(Math.min(lineNumber, view.state.doc.lines)) + + const lineElement = findLineElement(view, targetLine.from) + if (lineElement) { + lineElement.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }) + + if (options?.highlight) { + requestAnimationFrame(() => highlightLine(view, lineElement)) + } + return + } + + view.dispatch({ + effects: EditorView.scrollIntoView(targetLine.from, { + y: 'start' + }) + }) + + if (!options?.highlight) { + return + } + + setTimeout(() => { + const fallbackElement = findLineElement(view, targetLine.from) + if (fallbackElement) { + highlightLine(view, fallbackElement) + } + }, 200) + }, + [editorViewRef, findLineElement, highlightLine] + ) +} diff --git a/src/renderer/src/components/CodeEditor/index.tsx b/src/renderer/src/components/CodeEditor/index.tsx index 64c387ffd0..31c4ce798c 100644 --- a/src/renderer/src/components/CodeEditor/index.tsx +++ b/src/renderer/src/components/CodeEditor/index.tsx @@ -5,13 +5,14 @@ import diff from 'fast-diff' import { useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react' import { memo } from 'react' -import { useBlurHandler, useHeightListener, useLanguageExtensions, useSaveKeymap } from './hooks' +import { useBlurHandler, useHeightListener, useLanguageExtensions, useSaveKeymap, useScrollToLine } from './hooks' // 标记非用户编辑的变更 const External = Annotation.define() export interface CodeEditorHandles { save?: () => void + scrollToLine?: (lineNumber: number, options?: { highlight?: boolean }) => void } export interface CodeEditorProps { @@ -181,8 +182,11 @@ const CodeEditor = ({ ].flat() }, [extensions, langExtensions, wrapped, saveKeymapExtension, blurExtension, heightListenerExtension]) + const scrollToLine = useScrollToLine(editorViewRef) + useImperativeHandle(ref, () => ({ - save: handleSave + save: handleSave, + scrollToLine })) return ( diff --git a/src/renderer/src/components/HighlightText.tsx b/src/renderer/src/components/HighlightText.tsx new file mode 100644 index 0000000000..debf02c924 --- /dev/null +++ b/src/renderer/src/components/HighlightText.tsx @@ -0,0 +1,48 @@ +import { FC, memo, useMemo } from 'react' + +interface HighlightTextProps { + text: string + keyword: string + caseSensitive?: boolean + className?: string +} + +/** + * Text highlighting component that marks keyword matches + */ +const HighlightText: FC = ({ text, keyword, caseSensitive = false, className }) => { + const highlightedText = useMemo(() => { + if (!keyword || !text) { + return {text} + } + + // Escape regex special characters + const escapedKeyword = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const flags = caseSensitive ? 'g' : 'gi' + const regex = new RegExp(`(${escapedKeyword})`, flags) + + // Split text by keyword matches + const parts = text.split(regex) + + return ( + <> + {parts.map((part, index) => { + // Check if part matches keyword + const isMatch = regex.test(part) + regex.lastIndex = 0 // Reset regex state + + if (isMatch) { + return {part} + } + return {part} + })} + + ) + }, [text, keyword, caseSensitive]) + + const combinedClassName = className ? `ant-typography ${className}` : 'ant-typography' + + return {highlightedText} +} + +export default memo(HighlightText) diff --git a/src/renderer/src/components/RichEditor/constants.ts b/src/renderer/src/components/RichEditor/constants.ts new file mode 100644 index 0000000000..08b0ba7cf2 --- /dev/null +++ b/src/renderer/src/components/RichEditor/constants.ts @@ -0,0 +1,2 @@ +// Attribute used to store the original source line number in markdown editors +export const MARKDOWN_SOURCE_LINE_ATTR = 'data-source-line' diff --git a/src/renderer/src/components/RichEditor/index.tsx b/src/renderer/src/components/RichEditor/index.tsx index 83023dab9a..793ccda1ae 100644 --- a/src/renderer/src/components/RichEditor/index.tsx +++ b/src/renderer/src/components/RichEditor/index.tsx @@ -1,5 +1,6 @@ import { loggerService } from '@logger' import { ContentSearch, type ContentSearchRef } from '@renderer/components/ContentSearch' +import { MARKDOWN_SOURCE_LINE_ATTR } from '@renderer/components/RichEditor/constants' import DragHandle from '@tiptap/extension-drag-handle-react' import { EditorContent } from '@tiptap/react' import { Tooltip } from 'antd' @@ -29,6 +30,156 @@ import type { FormattingCommand, RichEditorProps, RichEditorRef } from './types' import { useRichEditor } from './useRichEditor' const logger = loggerService.withContext('RichEditor') +/** + * Find element by line number with fallback strategies: + * 1. Exact line + content match + * 2. Exact line match + * 3. Closest line <= target + */ +function findElementByLine(editorDom: HTMLElement, lineNumber: number, lineContent?: string): HTMLElement | null { + const allElements = Array.from(editorDom.querySelectorAll(`[${MARKDOWN_SOURCE_LINE_ATTR}]`)) as HTMLElement[] + if (allElements.length === 0) { + logger.warn('No elements with data-source-line attribute found') + return null + } + const exactMatches = editorDom.querySelectorAll( + `[${MARKDOWN_SOURCE_LINE_ATTR}="${lineNumber}"]` + ) as NodeListOf + + // Strategy 1: Exact line + content match + if (exactMatches.length > 1 && lineContent) { + for (const match of Array.from(exactMatches)) { + if (match.textContent?.includes(lineContent)) { + return match + } + } + } + + // Strategy 2: Exact line match + if (exactMatches.length > 0) { + return exactMatches[0] + } + + // Strategy 3: Closest line <= target + let closestElement: HTMLElement | null = null + let closestLine = 0 + + for (const el of allElements) { + const sourceLine = parseInt(el.getAttribute(MARKDOWN_SOURCE_LINE_ATTR) || '0', 10) + if (sourceLine <= lineNumber && sourceLine > closestLine) { + closestLine = sourceLine + closestElement = el + } + } + + return closestElement +} + +/** + * Create fixed-position highlight overlay at element location + * with boundary detection to prevent overflow and toolbar overlap + */ +function createHighlightOverlay(element: HTMLElement, container: HTMLElement): void { + try { + // Remove previous overlay + const previousOverlay = document.body.querySelector('.highlight-overlay') + if (previousOverlay) { + previousOverlay.remove() + } + + const editorWrapper = container.closest('.rich-editor-wrapper') + + // Create overlay at element position + const rect = element.getBoundingClientRect() + const overlay = document.createElement('div') + overlay.className = 'highlight-overlay animation-locate-highlight' + overlay.style.position = 'fixed' + overlay.style.left = `${rect.left}px` + overlay.style.top = `${rect.top}px` + overlay.style.width = `${rect.width}px` + overlay.style.height = `${rect.height}px` + overlay.style.pointerEvents = 'none' + overlay.style.zIndex = '9999' + overlay.style.borderRadius = '4px' + + document.body.appendChild(overlay) + + // Update overlay position and visibility on scroll + const updatePosition = () => { + const newRect = element.getBoundingClientRect() + const newContainerRect = container.getBoundingClientRect() + + // Update position + overlay.style.left = `${newRect.left}px` + overlay.style.top = `${newRect.top}px` + overlay.style.width = `${newRect.width}px` + overlay.style.height = `${newRect.height}px` + + // Get current toolbar bottom (it might change) + const currentToolbar = editorWrapper?.querySelector('[class*="ToolbarWrapper"]') + const currentToolbarRect = currentToolbar?.getBoundingClientRect() + const currentToolbarBottom = currentToolbarRect ? currentToolbarRect.bottom : newContainerRect.top + + // Check if overlay is within visible bounds + const overlayTop = newRect.top + const overlayBottom = newRect.bottom + const visibleTop = currentToolbarBottom // Don't overlap toolbar + const visibleBottom = newContainerRect.bottom + + // Hide overlay if any part is outside the visible container area + if (overlayTop < visibleTop || overlayBottom > visibleBottom) { + overlay.style.opacity = '0' + overlay.style.visibility = 'hidden' + } else { + overlay.style.opacity = '1' + overlay.style.visibility = 'visible' + } + } + + container.addEventListener('scroll', updatePosition) + + // Auto-remove after animation + const handleAnimationEnd = () => { + overlay.remove() + container.removeEventListener('scroll', updatePosition) + overlay.removeEventListener('animationend', handleAnimationEnd) + } + overlay.addEventListener('animationend', handleAnimationEnd) + } catch (error) { + logger.error('Failed to create highlight overlay:', error as Error) + } +} + +/** + * Scroll to element and show highlight after scroll completes + */ +function scrollAndHighlight(element: HTMLElement, container: HTMLElement): void { + element.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' }) + + let scrollTimeout: NodeJS.Timeout + const handleScroll = () => { + clearTimeout(scrollTimeout) + scrollTimeout = setTimeout(() => { + container.removeEventListener('scroll', handleScroll) + requestAnimationFrame(() => createHighlightOverlay(element, container)) + }, 150) + } + + container.addEventListener('scroll', handleScroll) + + // Fallback: if element already in view (no scroll happens) + setTimeout(() => { + const initialScrollTop = container.scrollTop + setTimeout(() => { + if (Math.abs(container.scrollTop - initialScrollTop) < 1) { + container.removeEventListener('scroll', handleScroll) + clearTimeout(scrollTimeout) + requestAnimationFrame(() => createHighlightOverlay(element, container)) + } + }, 200) + }, 50) +} + const RichEditor = ({ ref, initialContent = '', @@ -372,6 +523,22 @@ const RichEditor = ({ scrollContainerRef.current.scrollTop = value } }, + scrollToLine: (lineNumber: number, options?: { highlight?: boolean; lineContent?: string }) => { + if (!editor || !scrollContainerRef.current) return + + try { + const element = findElementByLine(editor.view.dom, lineNumber, options?.lineContent) + if (!element) return + + if (options?.highlight) { + scrollAndHighlight(element, scrollContainerRef.current) + } else { + element.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' }) + } + } catch (error) { + logger.error('Failed in scrollToLine:', error as Error) + } + }, // Dynamic command management registerCommand, registerToolbarCommand, diff --git a/src/renderer/src/components/RichEditor/types.ts b/src/renderer/src/components/RichEditor/types.ts index 48ae5bb112..15727e1a8d 100644 --- a/src/renderer/src/components/RichEditor/types.ts +++ b/src/renderer/src/components/RichEditor/types.ts @@ -111,6 +111,8 @@ export interface RichEditorRef { getScrollTop: () => number /** Set scrollTop of the editor scroll container */ setScrollTop: (value: number) => void + /** Scroll to specific line number in markdown */ + scrollToLine: (lineNumber: number, options?: { highlight?: boolean; lineContent?: string }) => void // Dynamic command management /** Register a new command/toolbar item */ registerCommand: (cmd: Command) => void diff --git a/src/renderer/src/components/RichEditor/useRichEditor.ts b/src/renderer/src/components/RichEditor/useRichEditor.ts index 1ece36fb00..576162fac7 100644 --- a/src/renderer/src/components/RichEditor/useRichEditor.ts +++ b/src/renderer/src/components/RichEditor/useRichEditor.ts @@ -2,6 +2,7 @@ import 'katex/dist/katex.min.css' import { TableKit } from '@cherrystudio/extension-table-plus' import { loggerService } from '@logger' +import { MARKDOWN_SOURCE_LINE_ATTR } from '@renderer/components/RichEditor/constants' import type { FormattingState } from '@renderer/components/RichEditor/types' import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { @@ -11,6 +12,7 @@ import { markdownToPreviewText } from '@renderer/utils/markdownConverter' import type { Editor } from '@tiptap/core' +import { Extension } from '@tiptap/core' import { TaskItem, TaskList } from '@tiptap/extension-list' import { migrateMathStrings } from '@tiptap/extension-mathematics' import Mention from '@tiptap/extension-mention' @@ -36,6 +38,31 @@ import { blobToArrayBuffer, compressImage, shouldCompressImage } from './helpers const logger = loggerService.withContext('useRichEditor') +// Create extension to preserve data-source-line attribute +const SourceLineAttribute = Extension.create({ + name: 'sourceLineAttribute', + addGlobalAttributes() { + return [ + { + types: ['paragraph', 'heading', 'blockquote', 'bulletList', 'orderedList', 'listItem', 'horizontalRule'], + attributes: { + dataSourceLine: { + default: null, + parseHTML: (element) => { + const value = element.getAttribute(MARKDOWN_SOURCE_LINE_ATTR) + return value + }, + renderHTML: (attributes) => { + if (!attributes.dataSourceLine) return {} + return { [MARKDOWN_SOURCE_LINE_ATTR]: attributes.dataSourceLine } + } + } + } + } + ] + } +}) + export interface UseRichEditorOptions { /** Initial markdown content */ initialContent?: string @@ -196,6 +223,7 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor // TipTap editor extensions const extensions = useMemo( () => [ + SourceLineAttribute, StarterKit.configure({ heading: { levels: [1, 2, 3, 4, 5, 6] diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index ee3d0ff110..6b0dd3c0c7 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1957,6 +1957,14 @@ "rename": "Rename", "rename_changed": "Due to security policies, the filename has been changed from {{original}} to {{final}}", "save": "Save to Notes", + "search": { + "both": "Name+Content", + "content": "Content", + "found_results": "Found {{count}} results (Name: {{nameCount}}, Content: {{contentCount}})", + "more_matches": "more matches", + "searching": "Searching...", + "show_less": "Show less" + }, "settings": { "data": { "apply": "Apply", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index e5573221e7..15675d265a 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1957,6 +1957,14 @@ "rename": "重命名", "rename_changed": "由于安全策略,文件名已从 {{original}} 更改为 {{final}}", "save": "保存到笔记", + "search": { + "both": "名称+内容", + "content": "内容", + "found_results": "找到 {{count}} 个结果 (名称: {{nameCount}}, 内容: {{contentCount}})", + "more_matches": "个匹配", + "searching": "搜索中...", + "show_less": "收起" + }, "settings": { "data": { "apply": "应用", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 8634254e55..020a0e5a19 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1957,6 +1957,14 @@ "rename": "重命名", "rename_changed": "由於安全策略,文件名已從 {{original}} 更改為 {{final}}", "save": "儲存到筆記", + "search": { + "both": "名稱+內容", + "content": "內容", + "found_results": "找到 {{count}} 個結果 (名稱: {{nameCount}}, 內容: {{contentCount}})", + "more_matches": "個匹配", + "searching": "搜索中...", + "show_less": "收起" + }, "settings": { "data": { "apply": "應用", diff --git a/src/renderer/src/pages/notes/NotesEditor.tsx b/src/renderer/src/pages/notes/NotesEditor.tsx index 18c2cfe9d5..66bbf22258 100644 --- a/src/renderer/src/pages/notes/NotesEditor.tsx +++ b/src/renderer/src/pages/notes/NotesEditor.tsx @@ -1,5 +1,5 @@ import ActionIconButton from '@renderer/components/Buttons/ActionIconButton' -import CodeEditor from '@renderer/components/CodeEditor' +import CodeEditor, { CodeEditorHandles } from '@renderer/components/CodeEditor' import { HSpaceBetweenStack } from '@renderer/components/Layout' import RichEditor from '@renderer/components/RichEditor' import { RichEditorRef } from '@renderer/components/RichEditor/types' @@ -20,11 +20,12 @@ interface NotesEditorProps { currentContent: string tokenCount: number editorRef: RefObject + codeEditorRef: RefObject onMarkdownChange: (content: string) => void } const NotesEditor: FC = memo( - ({ activeNodeId, currentContent, tokenCount, onMarkdownChange, editorRef }) => { + ({ activeNodeId, currentContent, tokenCount, onMarkdownChange, editorRef, codeEditorRef }) => { const { t } = useTranslation() const dispatch = useAppDispatch() const { settings } = useNotesSettings() @@ -59,6 +60,7 @@ const NotesEditor: FC = memo( {tmpViewMode === 'source' ? ( { const editorRef = useRef(null) + const codeEditorRef = useRef(null) const { t } = useTranslation() const { showWorkspace } = useShowWorkspace() const dispatch = useAppDispatch() @@ -76,6 +79,7 @@ const NotesPage: FC = () => { const lastFilePathRef = useRef(undefined) const isRenamingRef = useRef(false) const isCreatingNoteRef = useRef(false) + const pendingScrollRef = useRef<{ lineNumber: number; lineContent?: string } | null>(null) const activeFilePathRef = useRef(activeFilePath) const currentContentRef = useRef(currentContent) @@ -366,6 +370,32 @@ const NotesPage: FC = () => { } }, [currentContent, activeFilePath]) + // Execute pending scroll after file switch + useEffect(() => { + if (!pendingScrollRef.current || !currentContent) return + + const { lineNumber, lineContent } = pendingScrollRef.current + pendingScrollRef.current = null + + // Wait for DOM to update before scrolling + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const codeEditor = codeEditorRef.current + const richEditor = editorRef.current + + try { + if (codeEditor?.scrollToLine) { + codeEditor.scrollToLine(lineNumber, { highlight: true }) + } else if (richEditor?.scrollToLine) { + richEditor.scrollToLine(lineNumber, { highlight: true, lineContent }) + } + } catch (error) { + logger.error('Failed to execute pending scroll:', error as Error) + } + }) + }) + }, [activeFilePath, currentContent]) + // 切换文件时的清理工作 useEffect(() => { return () => { @@ -755,6 +785,53 @@ const NotesPage: FC = () => { } }, [currentContent, settings.defaultEditMode]) + // Listen for external requests to locate a specific line in a note + useEffect(() => { + const handleLocateNoteLine = ({ + noteId, + lineNumber, + lineContent + }: { + noteId: string + lineNumber: number + lineContent?: string + }) => { + const targetNode = findNode(notesTree, noteId) + + if (!targetNode || targetNode.type !== 'file') { + logger.warn('Target note not found or not a file', { noteId }) + return + } + + const needsSwitchFile = targetNode.externalPath !== activeFilePath + + if (needsSwitchFile) { + // switch to target note first then scroll to line + pendingScrollRef.current = { lineNumber, lineContent } + dispatch(setActiveFilePath(targetNode.externalPath)) + invalidateFileContent(targetNode.externalPath) + } else { + const richEditor = editorRef.current + const codeEditor = codeEditorRef.current + + try { + if (codeEditor?.scrollToLine) { + codeEditor.scrollToLine(lineNumber, { highlight: true }) + } else if (richEditor?.scrollToLine) { + richEditor.scrollToLine(lineNumber, { highlight: true, lineContent }) + } + } catch (error) { + logger.error('Failed to scroll to line:', error as Error) + } + } + } + + const unsubscribe = EventEmitter.on(EVENT_NAMES.LOCATE_NOTE_LINE, handleLocateNoteLine) + return () => { + unsubscribe() + } + }, [activeNode?.id, activeFilePath, notesTree, dispatch, invalidateFileContent]) + return ( @@ -800,6 +877,7 @@ const NotesPage: FC = () => { tokenCount={tokenCount} onMarkdownChange={handleMarkdownChange} editorRef={editorRef} + codeEditorRef={codeEditorRef} /> diff --git a/src/renderer/src/pages/notes/NotesSidebar.tsx b/src/renderer/src/pages/notes/NotesSidebar.tsx index 47a27030cd..d09c228f68 100644 --- a/src/renderer/src/pages/notes/NotesSidebar.tsx +++ b/src/renderer/src/pages/notes/NotesSidebar.tsx @@ -1,4 +1,5 @@ import { loggerService } from '@logger' +import HighlightText from '@renderer/components/HighlightText' import { DeleteIcon } from '@renderer/components/Icons' import SaveToKnowledgePopup from '@renderer/components/Popups/SaveToKnowledgePopup' import Scrollbar from '@renderer/components/Scrollbar' @@ -7,6 +8,8 @@ import { useKnowledgeBases } from '@renderer/hooks/useKnowledge' import { useActiveNode } from '@renderer/hooks/useNotesQuery' import NotesSidebarHeader from '@renderer/pages/notes/NotesSidebarHeader' import { fetchNoteSummary } from '@renderer/services/ApiService' +import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' +import { SearchMatch, SearchResult } from '@renderer/services/NotesSearchService' import { RootState, useAppSelector } from '@renderer/store' import { selectSortType } from '@renderer/store/note' import { NotesSortType, NotesTreeNode } from '@renderer/types/note' @@ -23,16 +26,20 @@ import { FileSearch, Folder, FolderOpen, + Loader2, Sparkles, Star, StarOff, - UploadIcon + UploadIcon, + X } from 'lucide-react' import { FC, memo, Ref, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' import styled from 'styled-components' +import { useFullTextSearch } from './hooks/useFullTextSearch' + interface NotesSidebarProps { onCreateFolder: (name: string, targetFolderId?: string) => void onCreateNote: (name: string, targetFolderId?: string) => void @@ -51,7 +58,7 @@ interface NotesSidebarProps { const logger = loggerService.withContext('NotesSidebar') interface TreeNodeProps { - node: NotesTreeNode + node: NotesTreeNode | SearchResult depth: number selectedFolderId?: string | null activeNodeId?: string @@ -71,6 +78,8 @@ interface TreeNodeProps { onDrop: (e: React.DragEvent, node: NotesTreeNode) => void onDragEnd: () => void renderChildren?: boolean // 控制是否渲染子节点 + searchKeyword?: string // 搜索关键词,用于高亮 + showMatches?: boolean // 是否显示匹配预览 openDropdownKey: string | null onDropdownOpenChange: (key: string | null) => void } @@ -97,10 +106,30 @@ const TreeNode = memo( onDrop, onDragEnd, renderChildren = true, + searchKeyword = '', + showMatches = false, openDropdownKey, onDropdownOpenChange }) => { const { t } = useTranslation() + const [showAllMatches, setShowAllMatches] = useState(false) + + // 检查是否是搜索结果 + const searchResult = 'matchType' in node ? (node as SearchResult) : null + const hasMatches = searchResult && searchResult.matches && searchResult.matches.length > 0 + + // 处理匹配项点击 + const handleMatchClick = useCallback( + (match: SearchMatch) => { + // 发送定位事件 + EventEmitter.emit(EVENT_NAMES.LOCATE_NOTE_LINE, { + noteId: node.id, + lineNumber: match.lineNumber, + lineContent: match.lineContent + }) + }, + [node] + ) const isActive = selectedFolderId ? node.type === 'folder' && node.id === selectedFolderId @@ -121,6 +150,37 @@ const TreeNode = memo( return '' } + const displayName = useMemo(() => { + if (!searchKeyword) { + return node.name + } + + const name = node.name ?? '' + if (!name) { + return name + } + + const keyword = searchKeyword + const nameLower = name.toLowerCase() + const keywordLower = keyword.toLowerCase() + const matchStart = nameLower.indexOf(keywordLower) + + if (matchStart === -1) { + return name + } + + const matchEnd = matchStart + keyword.length + const beforeMatch = Math.min(2, matchStart) + const contextStart = matchStart - beforeMatch + const contextLength = 50 + const contextEnd = Math.min(name.length, matchEnd + contextLength) + + const prefix = contextStart > 0 ? '...' : '' + const suffix = contextEnd < name.length ? '...' : '' + + return prefix + name.substring(contextStart, contextEnd) + suffix + }, [node.name, searchKeyword]) + return (
( size="small" /> ) : ( - {node.name} + + + {searchKeyword ? : node.name} + + {searchResult && searchResult.matchType && searchResult.matchType !== 'filename' && ( + + {searchResult.matchType === 'both' ? t('notes.search.both') : t('notes.search.content')} + + )} + )}
+ {showMatches && hasMatches && ( + + {(showAllMatches ? searchResult!.matches! : searchResult!.matches!.slice(0, 3)).map((match, idx) => ( + handleMatchClick(match)}> + {match.lineNumber} + + + + + ))} + {searchResult!.matches!.length > 3 && ( + { + e.stopPropagation() + setShowAllMatches(!showAllMatches) + }}> + {showAllMatches ? ( + <> + + {t('notes.search.show_less')} + + ) : ( + <> + +{searchResult!.matches!.length - 3}{' '} + {t('notes.search.more_matches')} + + )} + + )} + + )} + {renderChildren && node.type === 'folder' && node.expanded && hasChildren && (
{node.children!.map((child) => ( @@ -257,6 +359,31 @@ const NotesSidebar: FC = ({ const [openDropdownKey, setOpenDropdownKey] = useState(null) const dragNodeRef = useRef(null) const scrollbarRef = useRef(null) + const notesTreeRef = useRef(notesTree) + const trimmedSearchKeyword = useMemo(() => searchKeyword.trim(), [searchKeyword]) + const hasSearchKeyword = trimmedSearchKeyword.length > 0 + + // 全文搜索配置 + const searchOptions = useMemo( + () => ({ + debounceMs: 300, + maxResults: 100, + contextLength: 50, + caseSensitive: false, + maxFileSize: 10 * 1024 * 1024, // 10MB + enabled: isShowSearch + }), + [isShowSearch] + ) + + const { + search, + cancel, + reset, + isSearching, + results: searchResults, + stats: searchStats + } = useFullTextSearch(searchOptions) const inPlaceEdit = useInPlaceEdit({ onSave: (newName: string) => { @@ -514,6 +641,25 @@ const NotesSidebar: FC = ({ setIsShowSearch(!isShowSearch) }, [isShowSearch]) + // 同步 notesTree 到 ref + useEffect(() => { + notesTreeRef.current = notesTree + }, [notesTree]) + + // 触发全文搜索 + useEffect(() => { + if (!isShowSearch) { + reset() + return + } + + if (hasSearchKeyword) { + search(notesTreeRef.current, trimmedSearchKeyword) + } else { + reset() + } + }, [isShowSearch, hasSearchKeyword, trimmedSearchKeyword, search, reset]) + // Flatten tree nodes for virtualization and filtering const flattenedNodes = useMemo(() => { const flattenForVirtualization = ( @@ -537,11 +683,7 @@ const NotesSidebar: FC = ({ let result: NotesTreeNode[] = [] for (const node of nodes) { - if (isShowSearch && searchKeyword) { - if (node.type === 'file' && node.name.toLowerCase().includes(searchKeyword.toLowerCase())) { - result.push(node) - } - } else if (isShowStarred) { + if (isShowStarred) { if (node.type === 'file' && node.isStarred) { result.push(node) } @@ -553,7 +695,14 @@ const NotesSidebar: FC = ({ return result } - if (isShowStarred || isShowSearch) { + if (isShowSearch) { + if (hasSearchKeyword) { + return searchResults.map((result) => ({ node: result, depth: 0 })) + } + return [] // 搜索关键词为空 + } + + if (isShowStarred) { // For filtered views, return flat list without virtualization for simplicity const filteredNodes = flattenForFiltering(notesTree) return filteredNodes.map((node) => ({ node, depth: 0 })) @@ -561,7 +710,7 @@ const NotesSidebar: FC = ({ // For normal tree view, use hierarchical flattening for virtualization return flattenForVirtualization(notesTree) - }, [notesTree, isShowStarred, isShowSearch, searchKeyword]) + }, [notesTree, isShowStarred, isShowSearch, hasSearchKeyword, searchResults]) // Use virtualization only for normal tree view with many items const shouldUseVirtualization = !isShowStarred && !isShowSearch && flattenedNodes.length > 100 @@ -863,6 +1012,26 @@ const NotesSidebar: FC = ({ /> + {isShowSearch && isSearching && ( + + + {t('notes.search.searching')} + + + + + )} + {isShowSearch && !isSearching && hasSearchKeyword && searchStats.total > 0 && ( + + + {t('notes.search.found_results', { + count: searchStats.total, + nameCount: searchStats.fileNameMatches, + contentCount: searchStats.contentMatches + searchStats.bothMatches + })} + + + )} {shouldUseVirtualization ? ( = ({ onDragEnd={handleDragEnd} openDropdownKey={openDropdownKey} onDropdownOpenChange={setOpenDropdownKey} + searchKeyword={isShowSearch ? trimmedSearchKeyword : ''} + showMatches={isShowSearch} /> )) : notesTree.map((node) => ( @@ -1249,4 +1420,148 @@ const DropHintText = styled.div` font-style: italic; ` +// 搜索相关样式 +const SearchStatusBar = styled.div` + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background-color: var(--color-background-soft); + border-bottom: 0.5px solid var(--color-border); + font-size: 12px; + color: var(--color-text-2); + + .animate-spin { + animation: spin 1s linear infinite; + } + + @keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } +` + +const CancelButton = styled.button` + margin-left: auto; + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + border: none; + background-color: transparent; + color: var(--color-text-3); + cursor: pointer; + border-radius: 3px; + transition: all 0.2s ease; + + &:hover { + background-color: var(--color-background-mute); + color: var(--color-text); + } + + &:active { + background-color: var(--color-active); + } +` + +const NodeNameContainer = styled.div` + display: flex; + align-items: center; + gap: 6px; + flex: 1; + min-width: 0; +` + +const MatchBadge = styled.span<{ matchType: string }>` + display: inline-flex; + align-items: center; + padding: 0 4px; + height: 16px; + font-size: 10px; + line-height: 1; + border-radius: 2px; + background-color: ${(props) => + props.matchType === 'both' ? 'var(--color-primary-soft)' : 'var(--color-background-mute)'}; + color: ${(props) => (props.matchType === 'both' ? 'var(--color-primary)' : 'var(--color-text-3)')}; + font-weight: 500; + flex-shrink: 0; +` + +const SearchMatchesContainer = styled.div<{ depth: number }>` + margin-left: ${(props) => props.depth * 16 + 40}px; + margin-top: 4px; + margin-bottom: 8px; + padding: 6px 8px; + background-color: var(--color-background-mute); + border-radius: 4px; + border-left: 2px solid var(--color-primary-soft); +` + +const MatchItem = styled.div` + display: flex; + gap: 8px; + margin-bottom: 4px; + font-size: 12px; + padding: 4px 6px; + margin-left: -6px; + margin-right: -6px; + border-radius: 3px; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + background-color: var(--color-background-soft); + transform: translateX(2px); + } + + &:active { + background-color: var(--color-active); + } + + &:last-child { + margin-bottom: 0; + } +` + +const MatchLineNumber = styled.span` + color: var(--color-text-3); + font-family: monospace; + flex-shrink: 0; + width: 30px; +` + +const MatchContext = styled.div` + color: var(--color-text-2); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: monospace; +` + +const MoreMatches = styled.div<{ depth: number }>` + margin-top: 4px; + padding: 4px 6px; + margin-left: -6px; + margin-right: -6px; + font-size: 11px; + color: var(--color-text-3); + border-radius: 3px; + cursor: pointer; + display: flex; + align-items: center; + transition: all 0.15s ease; + + &:hover { + color: var(--color-text-2); + background-color: var(--color-background-soft); + } +` + export default memo(NotesSidebar) diff --git a/src/renderer/src/pages/notes/hooks/useFullTextSearch.ts b/src/renderer/src/pages/notes/hooks/useFullTextSearch.ts new file mode 100644 index 0000000000..6a2d12c2cf --- /dev/null +++ b/src/renderer/src/pages/notes/hooks/useFullTextSearch.ts @@ -0,0 +1,159 @@ +import { searchAllFiles, SearchOptions, SearchResult } from '@renderer/services/NotesSearchService' +import { NotesTreeNode } from '@renderer/types/note' +import { useCallback, useEffect, useRef, useState } from 'react' + +export interface UseFullTextSearchOptions extends SearchOptions { + debounceMs?: number + maxResults?: number + enabled?: boolean +} + +export interface UseFullTextSearchReturn { + search: (nodes: NotesTreeNode[], keyword: string) => void + cancel: () => void + reset: () => void + isSearching: boolean + results: SearchResult[] + stats: { + total: number + fileNameMatches: number + contentMatches: number + bothMatches: number + } + error: Error | null +} + +/** + * Full-text search hook for notes + */ +export function useFullTextSearch(options: UseFullTextSearchOptions = {}): UseFullTextSearchReturn { + const { debounceMs = 300, maxResults = 100, enabled = true, ...searchOptions } = options + + const [isSearching, setIsSearching] = useState(false) + const [results, setResults] = useState([]) + const [error, setError] = useState(null) + const [stats, setStats] = useState({ + total: 0, + fileNameMatches: 0, + contentMatches: 0, + bothMatches: 0 + }) + + const abortControllerRef = useRef(null) + const debounceTimerRef = useRef(null) + + // Store options in refs to avoid reference changes + const searchOptionsRef = useRef(searchOptions) + const maxResultsRef = useRef(maxResults) + const enabledRef = useRef(enabled) + + useEffect(() => { + searchOptionsRef.current = searchOptions + maxResultsRef.current = maxResults + enabledRef.current = enabled + }, [searchOptions, maxResults, enabled]) + + const cancel = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort() + abortControllerRef.current = null + } + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current) + debounceTimerRef.current = null + } + setIsSearching(false) + }, []) + + const reset = useCallback(() => { + cancel() + setResults([]) + setStats({ total: 0, fileNameMatches: 0, contentMatches: 0, bothMatches: 0 }) + setError(null) + }, [cancel]) + + const performSearch = useCallback( + async (nodes: NotesTreeNode[], keyword: string) => { + if (!enabledRef.current) { + return + } + + cancel() + + if (!keyword) { + setResults([]) + setStats({ total: 0, fileNameMatches: 0, contentMatches: 0, bothMatches: 0 }) + return + } + + setIsSearching(true) + setError(null) + + const abortController = new AbortController() + abortControllerRef.current = abortController + + try { + const searchResults = await searchAllFiles( + nodes, + keyword.trim(), + searchOptionsRef.current, + abortController.signal + ) + + if (abortController.signal.aborted) { + return + } + + const limitedResults = searchResults.slice(0, maxResultsRef.current) + + const newStats = { + total: limitedResults.length, + fileNameMatches: limitedResults.filter((r) => r.matchType === 'filename').length, + contentMatches: limitedResults.filter((r) => r.matchType === 'content').length, + bothMatches: limitedResults.filter((r) => r.matchType === 'both').length + } + + setResults(limitedResults) + setStats(newStats) + } catch (err) { + if (err instanceof Error && err.name !== 'AbortError') { + setError(err) + } + } finally { + if (!abortController.signal.aborted) { + setIsSearching(false) + } + } + }, + [cancel] + ) + + const search = useCallback( + (nodes: NotesTreeNode[], keyword: string) => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current) + } + + debounceTimerRef.current = setTimeout(() => { + performSearch(nodes, keyword) + }, debounceMs) + }, + [performSearch, debounceMs] + ) + + useEffect(() => { + return () => { + cancel() + } + }, [cancel]) + + return { + search, + cancel, + reset, + isSearching, + results, + stats, + error + } +} diff --git a/src/renderer/src/services/EventService.ts b/src/renderer/src/services/EventService.ts index 498beed049..ce5895fc36 100644 --- a/src/renderer/src/services/EventService.ts +++ b/src/renderer/src/services/EventService.ts @@ -24,6 +24,7 @@ export const EVENT_NAMES = { COPY_TOPIC_IMAGE: 'COPY_TOPIC_IMAGE', EXPORT_TOPIC_IMAGE: 'EXPORT_TOPIC_IMAGE', LOCATE_MESSAGE: 'LOCATE_MESSAGE', + LOCATE_NOTE_LINE: 'LOCATE_NOTE_LINE', ADD_NEW_TOPIC: 'ADD_NEW_TOPIC', RESEND_MESSAGE: 'RESEND_MESSAGE', SHOW_MODEL_SELECTOR: 'SHOW_MODEL_SELECTOR', diff --git a/src/renderer/src/services/NotesSearchService.ts b/src/renderer/src/services/NotesSearchService.ts new file mode 100644 index 0000000000..f4331ff166 --- /dev/null +++ b/src/renderer/src/services/NotesSearchService.ts @@ -0,0 +1,262 @@ +import { loggerService } from '@logger' +import { NotesTreeNode } from '@renderer/types/note' + +const logger = loggerService.withContext('NotesSearchService') + +/** + * Search match result + */ +export interface SearchMatch { + lineNumber: number + lineContent: string + matchStart: number + matchEnd: number + context: string +} + +/** + * Search result with match information + */ +export interface SearchResult extends NotesTreeNode { + matchType: 'filename' | 'content' | 'both' + matches?: SearchMatch[] + score: number +} + +/** + * Search options + */ +export interface SearchOptions { + caseSensitive?: boolean + useRegex?: boolean + maxFileSize?: number + maxMatchesPerFile?: number + contextLength?: number +} + +/** + * Escape regex special characters + */ +export function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +/** + * Calculate relevance score + * - Filename match has higher priority + * - More matches increase score + * - More recent updates increase score + */ +export function calculateRelevanceScore(node: NotesTreeNode, keyword: string, matches: SearchMatch[]): number { + let score = 0 + + // Exact filename match (highest weight) + if (node.name.toLowerCase() === keyword.toLowerCase()) { + score += 200 + } + // Filename contains match (high weight) + else if (node.name.toLowerCase().includes(keyword.toLowerCase())) { + score += 100 + } + + // Content match count + score += Math.min(matches.length * 2, 50) + + // Recent updates boost score + const daysSinceUpdate = (Date.now() - new Date(node.updatedAt).getTime()) / (1000 * 60 * 60 * 24) + score += Math.max(0, 10 - daysSinceUpdate) + + return score +} + +/** + * Search file content for keyword matches + */ +export async function searchFileContent( + node: NotesTreeNode, + keyword: string, + options: SearchOptions = {} +): Promise { + const { + caseSensitive = false, + useRegex = false, + maxFileSize = 10 * 1024 * 1024, // 10MB + maxMatchesPerFile = 50, + contextLength = 50 + } = options + + try { + if (node.type !== 'file') { + return null + } + + const content = await window.api.file.readExternal(node.externalPath) + + if (!content) { + return null + } + + if (content.length > maxFileSize) { + logger.warn(`File too large to search: ${node.externalPath} (${content.length} bytes)`) + return null + } + + const flags = caseSensitive ? 'g' : 'gi' + const pattern = useRegex ? new RegExp(keyword, flags) : new RegExp(escapeRegex(keyword), flags) + + const lines = content.split('\n') + const matches: SearchMatch[] = [] + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + pattern.lastIndex = 0 + + let match: RegExpExecArray | null + while ((match = pattern.exec(line)) !== null) { + const matchStart = match.index + const matchEnd = matchStart + match[0].length + + // Keep context short: only 2 chars before match, more after + const beforeMatch = Math.min(2, matchStart) + const contextStart = matchStart - beforeMatch + const contextEnd = Math.min(line.length, matchEnd + contextLength) + + // Add ellipsis if context doesn't start at line beginning + const prefix = contextStart > 0 ? '...' : '' + const contextText = prefix + line.substring(contextStart, contextEnd) + + matches.push({ + lineNumber: i + 1, + lineContent: line, + matchStart: beforeMatch + prefix.length, + matchEnd: matchEnd - matchStart + beforeMatch + prefix.length, + context: contextText + }) + + if (matches.length >= maxMatchesPerFile) { + break + } + } + + if (matches.length >= maxMatchesPerFile) { + break + } + } + + if (matches.length === 0) { + return null + } + + const score = calculateRelevanceScore(node, keyword, matches) + + return { + ...node, + matchType: 'content', + matches, + score + } + } catch (error) { + logger.error(`Failed to search file content for ${node.externalPath}:`, error as Error) + return null + } +} + +/** + * Check if filename matches keyword + */ +export function matchFileName(node: NotesTreeNode, keyword: string, caseSensitive = false): boolean { + const name = caseSensitive ? node.name : node.name.toLowerCase() + const key = caseSensitive ? keyword : keyword.toLowerCase() + return name.includes(key) +} + +/** + * Flatten tree to extract file nodes + */ +export function flattenTreeToFiles(nodes: NotesTreeNode[]): NotesTreeNode[] { + const result: NotesTreeNode[] = [] + + function traverse(nodes: NotesTreeNode[]) { + for (const node of nodes) { + if (node.type === 'file') { + result.push(node) + } + if (node.children && node.children.length > 0) { + traverse(node.children) + } + } + } + + traverse(nodes) + return result +} + +/** + * Search all files concurrently + */ +export async function searchAllFiles( + nodes: NotesTreeNode[], + keyword: string, + options: SearchOptions = {}, + signal?: AbortSignal +): Promise { + const startTime = performance.now() + const CONCURRENCY = 5 + const results: SearchResult[] = [] + + const fileNodes = flattenTreeToFiles(nodes) + + logger.debug( + `Starting full-text search: keyword="${keyword}", totalFiles=${fileNodes.length}, options=${JSON.stringify(options)}` + ) + + const queue = [...fileNodes] + + const worker = async () => { + while (queue.length > 0) { + if (signal?.aborted) { + break + } + + const node = queue.shift() + if (!node) break + + const nameMatch = matchFileName(node, keyword, options.caseSensitive) + const contentResult = await searchFileContent(node, keyword, options) + + if (nameMatch && contentResult) { + results.push({ + ...contentResult, + matchType: 'both', + score: contentResult.score + 100 + }) + } else if (nameMatch) { + results.push({ + ...node, + matchType: 'filename', + matches: [], + score: 100 + }) + } else if (contentResult) { + results.push(contentResult) + } + } + } + + await Promise.all(Array.from({ length: Math.min(CONCURRENCY, fileNodes.length) }, () => worker())) + + const sortedResults = results.sort((a, b) => b.score - a.score) + + const endTime = performance.now() + const duration = (endTime - startTime).toFixed(2) + + logger.debug( + `Full-text search completed: keyword="${keyword}", duration=${duration}ms, ` + + `totalFiles=${fileNodes.length}, resultsFound=${sortedResults.length}, ` + + `filenameMatches=${sortedResults.filter((r) => r.matchType === 'filename').length}, ` + + `contentMatches=${sortedResults.filter((r) => r.matchType === 'content').length}, ` + + `bothMatches=${sortedResults.filter((r) => r.matchType === 'both').length}` + ) + + return sortedResults +} diff --git a/src/renderer/src/utils/__tests__/markdownConverter.test.ts b/src/renderer/src/utils/__tests__/markdownConverter.test.ts index 44396d76ca..25a330b114 100644 --- a/src/renderer/src/utils/__tests__/markdownConverter.test.ts +++ b/src/renderer/src/utils/__tests__/markdownConverter.test.ts @@ -1,7 +1,18 @@ +import { MARKDOWN_SOURCE_LINE_ATTR } from '@renderer/components/RichEditor/constants' import { describe, expect, it } from 'vitest' import { htmlToMarkdown, markdownToHtml } from '../markdownConverter' +/** + * Strip markdown line number attributes for testing HTML structure + */ + +const LINE_NUMBER_REGEX = new RegExp(`\\s*${MARKDOWN_SOURCE_LINE_ATTR.replace(/-/g, '\\-')}="\\d+"`, 'g') + +function stripLineNumbers(html: string): string { + return html.replace(LINE_NUMBER_REGEX, '') +} + describe('markdownConverter', () => { describe('htmlToMarkdown', () => { it('should convert HTML to Markdown', () => { @@ -104,13 +115,13 @@ describe('markdownConverter', () => { describe('markdownToHtml', () => { it('should convert
to
', () => { const markdown = 'Text with
\nindentation
\nand without indentation' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe('

Text with
\nindentation
\nand without indentation

\n') }) it('should handle indentation in blockquotes', () => { const markdown = '> Quote line 1\n> Quote line 2 with indentation' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) // This should preserve indentation within the blockquote expect(result).toContain('Quote line 1') expect(result).toContain('Quote line 2 with indentation') @@ -118,7 +129,7 @@ describe('markdownConverter', () => { it('should preserve indentation in nested lists', () => { const markdown = '- Item 1\n - Nested item\n - Double nested\n with continued line' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) // Should create proper nested list structure expect(result).toContain('
    ') expect(result).toContain('
  • ') @@ -126,13 +137,13 @@ describe('markdownConverter', () => { it('should handle poetry or formatted text with indentation', () => { const markdown = 'Roses are red\n Violets are blue\n Sugar is sweet\n And so are you' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe('

    Roses are red\nViolets are blue\nSugar is sweet\nAnd so are you

    \n') }) it('should preserve indentation after line breaks with multiple paragraphs', () => { const markdown = 'First paragraph\n\n with indentation\n\n Second paragraph\n\nwith different indentation' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe( '

    First paragraph

    \n
    with indentation\n\nSecond paragraph\n

    with different indentation

    \n' ) @@ -140,14 +151,14 @@ describe('markdownConverter', () => { it('should handle zero-width indentation (just line break)', () => { const markdown = 'Hello\n\nWorld' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe('

    Hello

    \n

    World

    \n') }) it('should preserve indentation in mixed content', () => { const markdown = 'Normal text\n Indented continuation\n\n- List item\n List continuation\n\n> Quote\n> Indented quote' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe( '

    Normal text\nIndented continuation

    \n
      \n
    • List item\nList continuation
    • \n
    \n
    \n

    Quote\nIndented quote

    \n
    \n' ) @@ -155,19 +166,19 @@ describe('markdownConverter', () => { it('should convert Markdown to HTML', () => { const markdown = '# Hello World' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toContain('

    Hello World

    ') }) it('should convert math block syntax to HTML', () => { const markdown = '$$a+b+c$$' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toContain('
    ') }) it('should convert math inline syntax to HTML', () => { const markdown = '$a+b+c$' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toContain('') }) @@ -181,7 +192,7 @@ describe('markdownConverter', () => { \\nabla \\cdot \\vec{\\mathbf{B}} & = 0 \\end{array}$$` - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toContain( '
    ' ) @@ -189,7 +200,7 @@ describe('markdownConverter', () => { it('should convert task list syntax to proper HTML', () => { const markdown = '- [ ] abcd\n\n- [x] efgh\n\n' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toContain('data-type="taskList"') expect(result).toContain('data-type="taskItem"') expect(result).toContain('data-checked="false"') @@ -202,7 +213,7 @@ describe('markdownConverter', () => { it('should convert mixed task list with checked and unchecked items', () => { const markdown = '- [ ] First task\n\n- [x] Second task\n\n- [ ] Third task' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toContain('data-type="taskList"') expect(result).toContain('First task') expect(result).toContain('Second task') @@ -213,14 +224,14 @@ describe('markdownConverter', () => { it('should NOT convert standalone task syntax to task list', () => { const markdown = '[x] abcd' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toContain('

    [x] abcd

    ') expect(result).not.toContain('data-type="taskList"') }) it('should handle regular list items alongside task lists', () => { const markdown = '- Regular item\n\n- [ ] Task item\n\n- Another regular item' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toContain('data-type="taskList"') expect(result).toContain('Regular item') expect(result).toContain('Task item') @@ -241,25 +252,25 @@ describe('markdownConverter', () => { const markdown = `# 🌠 Screenshot ![](https://example.com/image.png)` - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe('

    🌠 Screenshot

    \n

    \n') }) it('should handle heading and paragraph', () => { const markdown = '# Hello\n\nHello' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe('

    Hello

    \n

    Hello

    \n') }) it('should convert code block to HTML', () => { const markdown = '```\nconsole.log("Hello, world!");\n```' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe('
    console.log("Hello, world!");\n
    ') }) it('should convert code block with language to HTML', () => { const markdown = '```javascript\nconsole.log("Hello, world!");\n```' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe( '
    console.log("Hello, world!");\n
    ' ) @@ -267,7 +278,7 @@ describe('markdownConverter', () => { it('should convert table to HTML', () => { const markdown = '| f | | |\n| --- | --- | --- |\n| | f | |\n| | | f |' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe( '\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
    f
    f
    f
    \n' ) @@ -275,7 +286,7 @@ describe('markdownConverter', () => { it('should escape XML-like tags in code blocks', () => { const markdown = '```jsx\nconst component = <>
    content
    \n```' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe( '
    const component = <><div>content</div></>\n
    ' ) @@ -283,13 +294,13 @@ describe('markdownConverter', () => { it('should escape XML-like tags in inline code', () => { const markdown = 'Use `<>` for fragments' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe('

    Use <> for fragments

    \n') }) it('shoud convert XML-like tags in paragraph', () => { const markdown = '' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe('

    \n') }) }) @@ -297,7 +308,7 @@ describe('markdownConverter', () => { describe('Task List with Labels', () => { it('should wrap task items with labels when label option is true', () => { const markdown = '- [ ] abcd\n\n- [x] efgh' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe( '
      \n
    • \n

      \n
    • \n
    • \n

      \n
    • \n
    \n' ) @@ -317,7 +328,7 @@ describe('markdownConverter', () => { const originalHtml = '
    ' const markdown = htmlToMarkdown(originalHtml) - const html = markdownToHtml(markdown) + const html = stripLineNumbers(markdownToHtml(markdown)) expect(html).toBe( '
      \n
    • \n
    \n' @@ -328,7 +339,7 @@ describe('markdownConverter', () => { const originalHtml = '
      \n
    • \n

      123

      \n
    • \n
    • \n

      \n
    • \n
    \n' const markdown = htmlToMarkdown(originalHtml) - const html = markdownToHtml(markdown) + const html = stripLineNumbers(markdownToHtml(markdown)) expect(html).toBe(originalHtml) }) @@ -383,7 +394,7 @@ describe('markdownConverter', () => { describe('markdown image', () => { it('should convert markdown image to HTML img tag', () => { const markdown = '![foo](train.jpg)' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe('

    foo

    \n') }) it('should convert markdown image with file:// protocol to HTML img tag', () => { @@ -420,7 +431,7 @@ describe('markdownConverter', () => { it('should handle hardbreak with backslash followed by indented text', () => { const markdown = 'Text with \\\n indentation \\\nand without indentation' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe('

    Text with
    \nindentation
    \nand without indentation

    \n') }) @@ -454,7 +465,7 @@ describe('markdownConverter', () => { it('should preserve custom XML tags mixed with regular markdown', () => { const markdown = '# Heading\n\nWidget content\n\n**Bold text**' - const html = markdownToHtml(markdown) + const html = stripLineNumbers(markdownToHtml(markdown)) const backToMarkdown = htmlToMarkdown(html) expect(html).toContain('

    Heading

    ') @@ -470,7 +481,7 @@ describe('markdownConverter', () => { it('should not add unwanted line breaks during simple text typing', () => { const html = '

    Hello world

    ' const markdown = htmlToMarkdown(html) - const backToHtml = markdownToHtml(markdown) + const backToHtml = stripLineNumbers(markdownToHtml(markdown)) expect(markdown).toBe('Hello world') expect(backToHtml).toBe('

    Hello world

    \n') @@ -479,7 +490,7 @@ describe('markdownConverter', () => { it('should preserve simple paragraph structure during round-trip conversion', () => { const originalHtml = '

    This is a simple paragraph being typed

    ' const markdown = htmlToMarkdown(originalHtml) - const backToHtml = markdownToHtml(markdown) + const backToHtml = stripLineNumbers(markdownToHtml(markdown)) expect(markdown).toBe('This is a simple paragraph being typed') expect(backToHtml).toBe('

    This is a simple paragraph being typed

    \n') }) @@ -520,4 +531,24 @@ cssclasses: expect(backToMarkdown).toBe(markdown) }) }) + + describe('should have markdown line number injected in HTML', () => { + it('should inject line numbers into paragraphs', () => { + const markdown = 'First paragraph\n\nSecond paragraph\n\nThird paragraph' + const result = markdownToHtml(markdown) + expect(result).toContain(`

    First paragraph

    `) + expect(result).toContain(`

    Second paragraph

    `) + expect(result).toContain(`

    Third paragraph

    `) + }) + + it('should inject line numbers into mixed content', () => { + const markdown = 'Text\n\n- List\n\n> Quote' + const result = markdownToHtml(markdown) + expect(result).toContain(`

    Text

    `) + expect(result).toContain(`
      `) + expect(result).toContain(`
    • List
    • `) + expect(result).toContain(`
      `) + expect(result).toContain(`

      Quote

      `) + }) + }) }) diff --git a/src/renderer/src/utils/markdownConverter.ts b/src/renderer/src/utils/markdownConverter.ts index 3b766cd32c..51d684612d 100644 --- a/src/renderer/src/utils/markdownConverter.ts +++ b/src/renderer/src/utils/markdownConverter.ts @@ -1,4 +1,5 @@ import { loggerService } from '@logger' +import { MARKDOWN_SOURCE_LINE_ATTR } from '@renderer/components/RichEditor/constants' import { TurndownPlugin } from '@truto/turndown-plugin-gfm' import he from 'he' import htmlTags, { type HtmlTags } from 'html-tags' @@ -85,12 +86,57 @@ const md = new MarkdownIt({ typographer: false // Enable smartypants and other sweet transforms }) +// Helper function to inject line number data attribute +function injectLineNumber(token: any, openTag: string): string { + if (token.map && token.map.length >= 2) { + const startLine = token.map[0] + 1 // Convert to 1-based line number + // Insert data attribute before the first closing > + // Handle both self-closing tags (e.g.,
      ) and opening tags (e.g.,

      ) + const result = openTag.replace(/(\s*\/?>)/, ` ${MARKDOWN_SOURCE_LINE_ATTR}="${startLine}"$1`) + logger.debug('injectLineNumber', { openTag, result, startLine, hasMap: !!token.map }) + return result + } + return openTag +} + +// Store the original renderer +const defaultRender = md.renderer.render.bind(md.renderer) + +// Override the main render method to inject line numbers +md.renderer.render = function (tokens, options, env) { + return defaultRender(tokens, options, env) +} + +// Override default rendering rules to add line numbers +const defaultBlockRules = [ + 'paragraph_open', + 'heading_open', + 'blockquote_open', + 'bullet_list_open', + 'ordered_list_open', + 'list_item_open', + 'table_open', + 'hr' +] + +defaultBlockRules.forEach((ruleName) => { + const original = md.renderer.rules[ruleName] + md.renderer.rules[ruleName] = function (tokens, idx, options, env, self) { + const token = tokens[idx] + let result = original ? original(tokens, idx, options, env, self) : self.renderToken(tokens, idx, options) + result = injectLineNumber(token, result) + return result + } +}) + // Override the code_block and code_inline renderers to properly escape HTML entities md.renderer.rules.code_block = function (tokens, idx) { const token = tokens[idx] const langName = token.info ? ` class="language-${token.info.trim()}"` : '' const escapedContent = he.encode(token.content, { useNamedReferences: false }) - return `

      ${escapedContent}
      ` + let html = `
      ${escapedContent}
      ` + html = injectLineNumber(token, html) + return html } md.renderer.rules.code_inline = function (tokens, idx) { @@ -103,7 +149,9 @@ md.renderer.rules.fence = function (tokens, idx) { const token = tokens[idx] const langName = token.info ? ` class="language-${token.info.trim()}"` : '' const escapedContent = he.encode(token.content, { useNamedReferences: false }) - return `
      ${escapedContent}
      ` + let html = `
      ${escapedContent}
      ` + html = injectLineNumber(token, html) + return html } // Custom task list plugin for markdown-it @@ -305,8 +353,11 @@ function yamlFrontMatterPlugin(md: MarkdownIt) { // Renderer: output YAML front matter as special HTML element md.renderer.rules.yaml_front_matter = (tokens: Array<{ content?: string }>, idx: number): string => { - const content = tokens[idx]?.content ?? '' - return `
      ${content}
      ` + const token = tokens[idx] + const content = token?.content ?? '' + let html = `
      ${content}
      ` + html = injectLineNumber(token, html) + return html } } @@ -408,9 +459,12 @@ function tipTapKatexPlugin(md: MarkdownIt) { // 2) Renderer: output TipTap-friendly container md.renderer.rules.math_block = (tokens: Array<{ content?: string }>, idx: number): string => { - const content = tokens[idx]?.content ?? '' + const token = tokens[idx] + const content = token?.content ?? '' const latexEscaped = he.encode(content, { useNamedReferences: true }) - return `
      ` + let html = `
      ` + html = injectLineNumber(token, html) + return html } // 3) Inline parser: recognize $...$ on a single line as inline math