feat: notes full text search (#10640)

* feat: notes full text search initial commit

* fix: update highlight overlay when scroll

* fix: reset note search result properly

* refactor: extract scrollToLine logic from CodeEditor into a custom hook

* fix: hide match overlay when overlap

* fix: truncate line with ellipsis around search match for better visibility

* fix: unified note search match highlight style
This commit is contained in:
defi-failure 2025-10-17 10:38:52 +08:00 committed by GitHub
parent fb680ce764
commit 1f7d2fa93f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1306 additions and 52 deletions

View File

@ -2,7 +2,7 @@ import { linter } from '@codemirror/lint' // statically imported by @uiw/codemir
import { EditorView } from '@codemirror/view' import { EditorView } from '@codemirror/view'
import { loggerService } from '@logger' import { loggerService } from '@logger'
import { Extension, keymap } from '@uiw/react-codemirror' import { Extension, keymap } from '@uiw/react-codemirror'
import { useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { getNormalizedExtension } from './utils' import { getNormalizedExtension } from './utils'
@ -203,3 +203,80 @@ export function useHeightListener({ onHeightChange }: UseHeightListenerProps) {
}) })
}, [onHeightChange]) }, [onHeightChange])
} }
interface UseScrollToLineOptions {
highlight?: boolean
}
export function useScrollToLine(editorViewRef: React.MutableRefObject<EditorView | null>) {
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]
)
}

View File

@ -5,13 +5,14 @@ import diff from 'fast-diff'
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react' import { useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
import { memo } 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<boolean>() const External = Annotation.define<boolean>()
export interface CodeEditorHandles { export interface CodeEditorHandles {
save?: () => void save?: () => void
scrollToLine?: (lineNumber: number, options?: { highlight?: boolean }) => void
} }
export interface CodeEditorProps { export interface CodeEditorProps {
@ -181,8 +182,11 @@ const CodeEditor = ({
].flat() ].flat()
}, [extensions, langExtensions, wrapped, saveKeymapExtension, blurExtension, heightListenerExtension]) }, [extensions, langExtensions, wrapped, saveKeymapExtension, blurExtension, heightListenerExtension])
const scrollToLine = useScrollToLine(editorViewRef)
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
save: handleSave save: handleSave,
scrollToLine
})) }))
return ( return (

View File

@ -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<HighlightTextProps> = ({ text, keyword, caseSensitive = false, className }) => {
const highlightedText = useMemo(() => {
if (!keyword || !text) {
return <span>{text}</span>
}
// 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 <mark key={index}>{part}</mark>
}
return <span key={index}>{part}</span>
})}
</>
)
}, [text, keyword, caseSensitive])
const combinedClassName = className ? `ant-typography ${className}` : 'ant-typography'
return <span className={combinedClassName}>{highlightedText}</span>
}
export default memo(HighlightText)

View File

@ -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'

View File

@ -1,5 +1,6 @@
import { loggerService } from '@logger' import { loggerService } from '@logger'
import { ContentSearch, type ContentSearchRef } from '@renderer/components/ContentSearch' 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 DragHandle from '@tiptap/extension-drag-handle-react'
import { EditorContent } from '@tiptap/react' import { EditorContent } from '@tiptap/react'
import { Tooltip } from 'antd' import { Tooltip } from 'antd'
@ -29,6 +30,156 @@ import type { FormattingCommand, RichEditorProps, RichEditorRef } from './types'
import { useRichEditor } from './useRichEditor' import { useRichEditor } from './useRichEditor'
const logger = loggerService.withContext('RichEditor') 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<HTMLElement>
// 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 = ({ const RichEditor = ({
ref, ref,
initialContent = '', initialContent = '',
@ -372,6 +523,22 @@ const RichEditor = ({
scrollContainerRef.current.scrollTop = value 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 // Dynamic command management
registerCommand, registerCommand,
registerToolbarCommand, registerToolbarCommand,

View File

@ -111,6 +111,8 @@ export interface RichEditorRef {
getScrollTop: () => number getScrollTop: () => number
/** Set scrollTop of the editor scroll container */ /** Set scrollTop of the editor scroll container */
setScrollTop: (value: number) => void setScrollTop: (value: number) => void
/** Scroll to specific line number in markdown */
scrollToLine: (lineNumber: number, options?: { highlight?: boolean; lineContent?: string }) => void
// Dynamic command management // Dynamic command management
/** Register a new command/toolbar item */ /** Register a new command/toolbar item */
registerCommand: (cmd: Command) => void registerCommand: (cmd: Command) => void

View File

@ -2,6 +2,7 @@ import 'katex/dist/katex.min.css'
import { TableKit } from '@cherrystudio/extension-table-plus' import { TableKit } from '@cherrystudio/extension-table-plus'
import { loggerService } from '@logger' import { loggerService } from '@logger'
import { MARKDOWN_SOURCE_LINE_ATTR } from '@renderer/components/RichEditor/constants'
import type { FormattingState } from '@renderer/components/RichEditor/types' import type { FormattingState } from '@renderer/components/RichEditor/types'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { import {
@ -11,6 +12,7 @@ import {
markdownToPreviewText markdownToPreviewText
} from '@renderer/utils/markdownConverter' } from '@renderer/utils/markdownConverter'
import type { Editor } from '@tiptap/core' import type { Editor } from '@tiptap/core'
import { Extension } from '@tiptap/core'
import { TaskItem, TaskList } from '@tiptap/extension-list' import { TaskItem, TaskList } from '@tiptap/extension-list'
import { migrateMathStrings } from '@tiptap/extension-mathematics' import { migrateMathStrings } from '@tiptap/extension-mathematics'
import Mention from '@tiptap/extension-mention' import Mention from '@tiptap/extension-mention'
@ -36,6 +38,31 @@ import { blobToArrayBuffer, compressImage, shouldCompressImage } from './helpers
const logger = loggerService.withContext('useRichEditor') 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 { export interface UseRichEditorOptions {
/** Initial markdown content */ /** Initial markdown content */
initialContent?: string initialContent?: string
@ -196,6 +223,7 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
// TipTap editor extensions // TipTap editor extensions
const extensions = useMemo( const extensions = useMemo(
() => [ () => [
SourceLineAttribute,
StarterKit.configure({ StarterKit.configure({
heading: { heading: {
levels: [1, 2, 3, 4, 5, 6] levels: [1, 2, 3, 4, 5, 6]

View File

@ -1957,6 +1957,14 @@
"rename": "Rename", "rename": "Rename",
"rename_changed": "Due to security policies, the filename has been changed from {{original}} to {{final}}", "rename_changed": "Due to security policies, the filename has been changed from {{original}} to {{final}}",
"save": "Save to Notes", "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": { "settings": {
"data": { "data": {
"apply": "Apply", "apply": "Apply",

View File

@ -1957,6 +1957,14 @@
"rename": "重命名", "rename": "重命名",
"rename_changed": "由于安全策略,文件名已从 {{original}} 更改为 {{final}}", "rename_changed": "由于安全策略,文件名已从 {{original}} 更改为 {{final}}",
"save": "保存到笔记", "save": "保存到笔记",
"search": {
"both": "名称+内容",
"content": "内容",
"found_results": "找到 {{count}} 个结果 (名称: {{nameCount}}, 内容: {{contentCount}})",
"more_matches": "个匹配",
"searching": "搜索中...",
"show_less": "收起"
},
"settings": { "settings": {
"data": { "data": {
"apply": "应用", "apply": "应用",

View File

@ -1957,6 +1957,14 @@
"rename": "重命名", "rename": "重命名",
"rename_changed": "由於安全策略,文件名已從 {{original}} 更改為 {{final}}", "rename_changed": "由於安全策略,文件名已從 {{original}} 更改為 {{final}}",
"save": "儲存到筆記", "save": "儲存到筆記",
"search": {
"both": "名稱+內容",
"content": "內容",
"found_results": "找到 {{count}} 個結果 (名稱: {{nameCount}}, 內容: {{contentCount}})",
"more_matches": "個匹配",
"searching": "搜索中...",
"show_less": "收起"
},
"settings": { "settings": {
"data": { "data": {
"apply": "應用", "apply": "應用",

View File

@ -1,5 +1,5 @@
import ActionIconButton from '@renderer/components/Buttons/ActionIconButton' 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 { HSpaceBetweenStack } from '@renderer/components/Layout'
import RichEditor from '@renderer/components/RichEditor' import RichEditor from '@renderer/components/RichEditor'
import { RichEditorRef } from '@renderer/components/RichEditor/types' import { RichEditorRef } from '@renderer/components/RichEditor/types'
@ -20,11 +20,12 @@ interface NotesEditorProps {
currentContent: string currentContent: string
tokenCount: number tokenCount: number
editorRef: RefObject<RichEditorRef | null> editorRef: RefObject<RichEditorRef | null>
codeEditorRef: RefObject<CodeEditorHandles | null>
onMarkdownChange: (content: string) => void onMarkdownChange: (content: string) => void
} }
const NotesEditor: FC<NotesEditorProps> = memo( const NotesEditor: FC<NotesEditorProps> = memo(
({ activeNodeId, currentContent, tokenCount, onMarkdownChange, editorRef }) => { ({ activeNodeId, currentContent, tokenCount, onMarkdownChange, editorRef, codeEditorRef }) => {
const { t } = useTranslation() const { t } = useTranslation()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const { settings } = useNotesSettings() const { settings } = useNotesSettings()
@ -59,6 +60,7 @@ const NotesEditor: FC<NotesEditorProps> = memo(
{tmpViewMode === 'source' ? ( {tmpViewMode === 'source' ? (
<SourceEditorWrapper isFullWidth={settings.isFullWidth} fontSize={settings.fontSize}> <SourceEditorWrapper isFullWidth={settings.isFullWidth} fontSize={settings.fontSize}>
<CodeEditor <CodeEditor
ref={codeEditorRef}
value={currentContent} value={currentContent}
language="markdown" language="markdown"
onChange={onMarkdownChange} onChange={onMarkdownChange}

View File

@ -1,9 +1,11 @@
import { loggerService } from '@logger' import { loggerService } from '@logger'
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar' import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import { CodeEditorHandles } from '@renderer/components/CodeEditor'
import { RichEditorRef } from '@renderer/components/RichEditor/types' import { RichEditorRef } from '@renderer/components/RichEditor/types'
import { useActiveNode, useFileContent, useFileContentSync } from '@renderer/hooks/useNotesQuery' import { useActiveNode, useFileContent, useFileContentSync } from '@renderer/hooks/useNotesQuery'
import { useNotesSettings } from '@renderer/hooks/useNotesSettings' import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace' import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { import {
addDir, addDir,
addNote, addNote,
@ -51,6 +53,7 @@ const logger = loggerService.withContext('NotesPage')
const NotesPage: FC = () => { const NotesPage: FC = () => {
const editorRef = useRef<RichEditorRef>(null) const editorRef = useRef<RichEditorRef>(null)
const codeEditorRef = useRef<CodeEditorHandles>(null)
const { t } = useTranslation() const { t } = useTranslation()
const { showWorkspace } = useShowWorkspace() const { showWorkspace } = useShowWorkspace()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
@ -76,6 +79,7 @@ const NotesPage: FC = () => {
const lastFilePathRef = useRef<string | undefined>(undefined) const lastFilePathRef = useRef<string | undefined>(undefined)
const isRenamingRef = useRef(false) const isRenamingRef = useRef(false)
const isCreatingNoteRef = useRef(false) const isCreatingNoteRef = useRef(false)
const pendingScrollRef = useRef<{ lineNumber: number; lineContent?: string } | null>(null)
const activeFilePathRef = useRef<string | undefined>(activeFilePath) const activeFilePathRef = useRef<string | undefined>(activeFilePath)
const currentContentRef = useRef(currentContent) const currentContentRef = useRef(currentContent)
@ -366,6 +370,32 @@ const NotesPage: FC = () => {
} }
}, [currentContent, activeFilePath]) }, [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(() => { useEffect(() => {
return () => { return () => {
@ -755,6 +785,53 @@ const NotesPage: FC = () => {
} }
}, [currentContent, settings.defaultEditMode]) }, [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 ( return (
<Container id="notes-page"> <Container id="notes-page">
<Navbar> <Navbar>
@ -800,6 +877,7 @@ const NotesPage: FC = () => {
tokenCount={tokenCount} tokenCount={tokenCount}
onMarkdownChange={handleMarkdownChange} onMarkdownChange={handleMarkdownChange}
editorRef={editorRef} editorRef={editorRef}
codeEditorRef={codeEditorRef}
/> />
</EditorWrapper> </EditorWrapper>
</ContentContainer> </ContentContainer>

View File

@ -1,4 +1,5 @@
import { loggerService } from '@logger' import { loggerService } from '@logger'
import HighlightText from '@renderer/components/HighlightText'
import { DeleteIcon } from '@renderer/components/Icons' import { DeleteIcon } from '@renderer/components/Icons'
import SaveToKnowledgePopup from '@renderer/components/Popups/SaveToKnowledgePopup' import SaveToKnowledgePopup from '@renderer/components/Popups/SaveToKnowledgePopup'
import Scrollbar from '@renderer/components/Scrollbar' import Scrollbar from '@renderer/components/Scrollbar'
@ -7,6 +8,8 @@ import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
import { useActiveNode } from '@renderer/hooks/useNotesQuery' import { useActiveNode } from '@renderer/hooks/useNotesQuery'
import NotesSidebarHeader from '@renderer/pages/notes/NotesSidebarHeader' import NotesSidebarHeader from '@renderer/pages/notes/NotesSidebarHeader'
import { fetchNoteSummary } from '@renderer/services/ApiService' 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 { RootState, useAppSelector } from '@renderer/store'
import { selectSortType } from '@renderer/store/note' import { selectSortType } from '@renderer/store/note'
import { NotesSortType, NotesTreeNode } from '@renderer/types/note' import { NotesSortType, NotesTreeNode } from '@renderer/types/note'
@ -23,16 +26,20 @@ import {
FileSearch, FileSearch,
Folder, Folder,
FolderOpen, FolderOpen,
Loader2,
Sparkles, Sparkles,
Star, Star,
StarOff, StarOff,
UploadIcon UploadIcon,
X
} from 'lucide-react' } from 'lucide-react'
import { FC, memo, Ref, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { FC, memo, Ref, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import styled from 'styled-components' import styled from 'styled-components'
import { useFullTextSearch } from './hooks/useFullTextSearch'
interface NotesSidebarProps { interface NotesSidebarProps {
onCreateFolder: (name: string, targetFolderId?: string) => void onCreateFolder: (name: string, targetFolderId?: string) => void
onCreateNote: (name: string, targetFolderId?: string) => void onCreateNote: (name: string, targetFolderId?: string) => void
@ -51,7 +58,7 @@ interface NotesSidebarProps {
const logger = loggerService.withContext('NotesSidebar') const logger = loggerService.withContext('NotesSidebar')
interface TreeNodeProps { interface TreeNodeProps {
node: NotesTreeNode node: NotesTreeNode | SearchResult
depth: number depth: number
selectedFolderId?: string | null selectedFolderId?: string | null
activeNodeId?: string activeNodeId?: string
@ -71,6 +78,8 @@ interface TreeNodeProps {
onDrop: (e: React.DragEvent, node: NotesTreeNode) => void onDrop: (e: React.DragEvent, node: NotesTreeNode) => void
onDragEnd: () => void onDragEnd: () => void
renderChildren?: boolean // 控制是否渲染子节点 renderChildren?: boolean // 控制是否渲染子节点
searchKeyword?: string // 搜索关键词,用于高亮
showMatches?: boolean // 是否显示匹配预览
openDropdownKey: string | null openDropdownKey: string | null
onDropdownOpenChange: (key: string | null) => void onDropdownOpenChange: (key: string | null) => void
} }
@ -97,10 +106,30 @@ const TreeNode = memo<TreeNodeProps>(
onDrop, onDrop,
onDragEnd, onDragEnd,
renderChildren = true, renderChildren = true,
searchKeyword = '',
showMatches = false,
openDropdownKey, openDropdownKey,
onDropdownOpenChange onDropdownOpenChange
}) => { }) => {
const { t } = useTranslation() 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 const isActive = selectedFolderId
? node.type === 'folder' && node.id === selectedFolderId ? node.type === 'folder' && node.id === selectedFolderId
@ -121,6 +150,37 @@ const TreeNode = memo<TreeNodeProps>(
return '' 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 ( return (
<div key={node.id}> <div key={node.id}>
<Dropdown <Dropdown
@ -182,13 +242,55 @@ const TreeNode = memo<TreeNodeProps>(
size="small" size="small"
/> />
) : ( ) : (
<NodeName className={getNodeNameClassName()}>{node.name}</NodeName> <NodeNameContainer>
<NodeName className={getNodeNameClassName()}>
{searchKeyword ? <HighlightText text={displayName} keyword={searchKeyword} /> : node.name}
</NodeName>
{searchResult && searchResult.matchType && searchResult.matchType !== 'filename' && (
<MatchBadge matchType={searchResult.matchType}>
{searchResult.matchType === 'both' ? t('notes.search.both') : t('notes.search.content')}
</MatchBadge>
)}
</NodeNameContainer>
)} )}
</TreeNodeContent> </TreeNodeContent>
</TreeNodeContainer> </TreeNodeContainer>
</div> </div>
</Dropdown> </Dropdown>
{showMatches && hasMatches && (
<SearchMatchesContainer depth={depth}>
{(showAllMatches ? searchResult!.matches! : searchResult!.matches!.slice(0, 3)).map((match, idx) => (
<MatchItem key={idx} onClick={() => handleMatchClick(match)}>
<MatchLineNumber>{match.lineNumber}</MatchLineNumber>
<MatchContext>
<HighlightText text={match.context} keyword={searchKeyword} />
</MatchContext>
</MatchItem>
))}
{searchResult!.matches!.length > 3 && (
<MoreMatches
depth={depth}
onClick={(e) => {
e.stopPropagation()
setShowAllMatches(!showAllMatches)
}}>
{showAllMatches ? (
<>
<ChevronDown size={12} style={{ marginRight: 4 }} />
{t('notes.search.show_less')}
</>
) : (
<>
<ChevronRight size={12} style={{ marginRight: 4 }} />+{searchResult!.matches!.length - 3}{' '}
{t('notes.search.more_matches')}
</>
)}
</MoreMatches>
)}
</SearchMatchesContainer>
)}
{renderChildren && node.type === 'folder' && node.expanded && hasChildren && ( {renderChildren && node.type === 'folder' && node.expanded && hasChildren && (
<div> <div>
{node.children!.map((child) => ( {node.children!.map((child) => (
@ -257,6 +359,31 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
const [openDropdownKey, setOpenDropdownKey] = useState<string | null>(null) const [openDropdownKey, setOpenDropdownKey] = useState<string | null>(null)
const dragNodeRef = useRef<HTMLDivElement | null>(null) const dragNodeRef = useRef<HTMLDivElement | null>(null)
const scrollbarRef = useRef<any>(null) const scrollbarRef = useRef<any>(null)
const notesTreeRef = useRef<NotesTreeNode[]>(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({ const inPlaceEdit = useInPlaceEdit({
onSave: (newName: string) => { onSave: (newName: string) => {
@ -514,6 +641,25 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
setIsShowSearch(!isShowSearch) setIsShowSearch(!isShowSearch)
}, [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 // Flatten tree nodes for virtualization and filtering
const flattenedNodes = useMemo(() => { const flattenedNodes = useMemo(() => {
const flattenForVirtualization = ( const flattenForVirtualization = (
@ -537,11 +683,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
let result: NotesTreeNode[] = [] let result: NotesTreeNode[] = []
for (const node of nodes) { for (const node of nodes) {
if (isShowSearch && searchKeyword) { if (isShowStarred) {
if (node.type === 'file' && node.name.toLowerCase().includes(searchKeyword.toLowerCase())) {
result.push(node)
}
} else if (isShowStarred) {
if (node.type === 'file' && node.isStarred) { if (node.type === 'file' && node.isStarred) {
result.push(node) result.push(node)
} }
@ -553,7 +695,14 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
return result 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 // For filtered views, return flat list without virtualization for simplicity
const filteredNodes = flattenForFiltering(notesTree) const filteredNodes = flattenForFiltering(notesTree)
return filteredNodes.map((node) => ({ node, depth: 0 })) return filteredNodes.map((node) => ({ node, depth: 0 }))
@ -561,7 +710,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
// For normal tree view, use hierarchical flattening for virtualization // For normal tree view, use hierarchical flattening for virtualization
return flattenForVirtualization(notesTree) return flattenForVirtualization(notesTree)
}, [notesTree, isShowStarred, isShowSearch, searchKeyword]) }, [notesTree, isShowStarred, isShowSearch, hasSearchKeyword, searchResults])
// Use virtualization only for normal tree view with many items // Use virtualization only for normal tree view with many items
const shouldUseVirtualization = !isShowStarred && !isShowSearch && flattenedNodes.length > 100 const shouldUseVirtualization = !isShowStarred && !isShowSearch && flattenedNodes.length > 100
@ -863,6 +1012,26 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
/> />
<NotesTreeContainer> <NotesTreeContainer>
{isShowSearch && isSearching && (
<SearchStatusBar>
<Loader2 size={14} className="animate-spin" />
<span>{t('notes.search.searching')}</span>
<CancelButton onClick={cancel} title={t('common.cancel')}>
<X size={14} />
</CancelButton>
</SearchStatusBar>
)}
{isShowSearch && !isSearching && hasSearchKeyword && searchStats.total > 0 && (
<SearchStatusBar>
<span>
{t('notes.search.found_results', {
count: searchStats.total,
nameCount: searchStats.fileNameMatches,
contentCount: searchStats.contentMatches + searchStats.bothMatches
})}
</span>
</SearchStatusBar>
)}
{shouldUseVirtualization ? ( {shouldUseVirtualization ? (
<Dropdown <Dropdown
menu={{ items: getEmptyAreaMenuItems() }} menu={{ items: getEmptyAreaMenuItems() }}
@ -967,6 +1136,8 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
openDropdownKey={openDropdownKey} openDropdownKey={openDropdownKey}
onDropdownOpenChange={setOpenDropdownKey} onDropdownOpenChange={setOpenDropdownKey}
searchKeyword={isShowSearch ? trimmedSearchKeyword : ''}
showMatches={isShowSearch}
/> />
)) ))
: notesTree.map((node) => ( : notesTree.map((node) => (
@ -1249,4 +1420,148 @@ const DropHintText = styled.div`
font-style: italic; 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) export default memo(NotesSidebar)

View File

@ -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<SearchResult[]>([])
const [error, setError] = useState<Error | null>(null)
const [stats, setStats] = useState({
total: 0,
fileNameMatches: 0,
contentMatches: 0,
bothMatches: 0
})
const abortControllerRef = useRef<AbortController | null>(null)
const debounceTimerRef = useRef<NodeJS.Timeout | null>(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
}
}

View File

@ -24,6 +24,7 @@ export const EVENT_NAMES = {
COPY_TOPIC_IMAGE: 'COPY_TOPIC_IMAGE', COPY_TOPIC_IMAGE: 'COPY_TOPIC_IMAGE',
EXPORT_TOPIC_IMAGE: 'EXPORT_TOPIC_IMAGE', EXPORT_TOPIC_IMAGE: 'EXPORT_TOPIC_IMAGE',
LOCATE_MESSAGE: 'LOCATE_MESSAGE', LOCATE_MESSAGE: 'LOCATE_MESSAGE',
LOCATE_NOTE_LINE: 'LOCATE_NOTE_LINE',
ADD_NEW_TOPIC: 'ADD_NEW_TOPIC', ADD_NEW_TOPIC: 'ADD_NEW_TOPIC',
RESEND_MESSAGE: 'RESEND_MESSAGE', RESEND_MESSAGE: 'RESEND_MESSAGE',
SHOW_MODEL_SELECTOR: 'SHOW_MODEL_SELECTOR', SHOW_MODEL_SELECTOR: 'SHOW_MODEL_SELECTOR',

View File

@ -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<SearchResult | null> {
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<SearchResult[]> {
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
}

View File

@ -1,7 +1,18 @@
import { MARKDOWN_SOURCE_LINE_ATTR } from '@renderer/components/RichEditor/constants'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { htmlToMarkdown, markdownToHtml } from '../markdownConverter' 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('markdownConverter', () => {
describe('htmlToMarkdown', () => { describe('htmlToMarkdown', () => {
it('should convert HTML to Markdown', () => { it('should convert HTML to Markdown', () => {
@ -104,13 +115,13 @@ describe('markdownConverter', () => {
describe('markdownToHtml', () => { describe('markdownToHtml', () => {
it('should convert <br> to <br>', () => { it('should convert <br> to <br>', () => {
const markdown = 'Text with<br>\nindentation<br>\nand without indentation' const markdown = 'Text with<br>\nindentation<br>\nand without indentation'
const result = markdownToHtml(markdown) const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toBe('<p>Text with<br>\nindentation<br>\nand without indentation</p>\n') expect(result).toBe('<p>Text with<br>\nindentation<br>\nand without indentation</p>\n')
}) })
it('should handle indentation in blockquotes', () => { it('should handle indentation in blockquotes', () => {
const markdown = '> Quote line 1\n> Quote line 2 with indentation' 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 // This should preserve indentation within the blockquote
expect(result).toContain('Quote line 1') expect(result).toContain('Quote line 1')
expect(result).toContain('Quote line 2 with indentation') expect(result).toContain('Quote line 2 with indentation')
@ -118,7 +129,7 @@ describe('markdownConverter', () => {
it('should preserve indentation in nested lists', () => { it('should preserve indentation in nested lists', () => {
const markdown = '- Item 1\n - Nested item\n - Double nested\n with continued line' 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 // Should create proper nested list structure
expect(result).toContain('<ul>') expect(result).toContain('<ul>')
expect(result).toContain('<li>') expect(result).toContain('<li>')
@ -126,13 +137,13 @@ describe('markdownConverter', () => {
it('should handle poetry or formatted text with indentation', () => { 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 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('<p>Roses are red\nViolets are blue\nSugar is sweet\nAnd so are you</p>\n') expect(result).toBe('<p>Roses are red\nViolets are blue\nSugar is sweet\nAnd so are you</p>\n')
}) })
it('should preserve indentation after line breaks with multiple paragraphs', () => { 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 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( expect(result).toBe(
'<p>First paragraph</p>\n<pre><code>with indentation\n\nSecond paragraph\n</code></pre><p>with different indentation</p>\n' '<p>First paragraph</p>\n<pre><code>with indentation\n\nSecond paragraph\n</code></pre><p>with different indentation</p>\n'
) )
@ -140,14 +151,14 @@ describe('markdownConverter', () => {
it('should handle zero-width indentation (just line break)', () => { it('should handle zero-width indentation (just line break)', () => {
const markdown = 'Hello\n\nWorld' const markdown = 'Hello\n\nWorld'
const result = markdownToHtml(markdown) const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toBe('<p>Hello</p>\n<p>World</p>\n') expect(result).toBe('<p>Hello</p>\n<p>World</p>\n')
}) })
it('should preserve indentation in mixed content', () => { it('should preserve indentation in mixed content', () => {
const markdown = const markdown =
'Normal text\n Indented continuation\n\n- List item\n List continuation\n\n> Quote\n> Indented quote' '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( expect(result).toBe(
'<p>Normal text\nIndented continuation</p>\n<ul>\n<li>List item\nList continuation</li>\n</ul>\n<blockquote>\n<p>Quote\nIndented quote</p>\n</blockquote>\n' '<p>Normal text\nIndented continuation</p>\n<ul>\n<li>List item\nList continuation</li>\n</ul>\n<blockquote>\n<p>Quote\nIndented quote</p>\n</blockquote>\n'
) )
@ -155,19 +166,19 @@ describe('markdownConverter', () => {
it('should convert Markdown to HTML', () => { it('should convert Markdown to HTML', () => {
const markdown = '# Hello World' const markdown = '# Hello World'
const result = markdownToHtml(markdown) const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toContain('<h1>Hello World</h1>') expect(result).toContain('<h1>Hello World</h1>')
}) })
it('should convert math block syntax to HTML', () => { it('should convert math block syntax to HTML', () => {
const markdown = '$$a+b+c$$' const markdown = '$$a+b+c$$'
const result = markdownToHtml(markdown) const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toContain('<div data-latex="a+b+c" data-type="block-math"></div>') expect(result).toContain('<div data-latex="a+b+c" data-type="block-math"></div>')
}) })
it('should convert math inline syntax to HTML', () => { it('should convert math inline syntax to HTML', () => {
const markdown = '$a+b+c$' const markdown = '$a+b+c$'
const result = markdownToHtml(markdown) const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toContain('<span data-latex="a+b+c" data-type="inline-math"></span>') expect(result).toContain('<span data-latex="a+b+c" data-type="inline-math"></span>')
}) })
@ -181,7 +192,7 @@ describe('markdownConverter', () => {
\\nabla \\cdot \\vec{\\mathbf{B}} & = 0 \\nabla \\cdot \\vec{\\mathbf{B}} & = 0
\\end{array}$$` \\end{array}$$`
const result = markdownToHtml(markdown) const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toContain( expect(result).toContain(
'<div data-latex="\\begin{array}{c}\n\\nabla \\times \\vec{\\mathbf{B}} -\\, \\frac1c\\, \\frac{\\partial\\vec{\\mathbf{E}}}{\\partial t} &amp;\n= \\frac{4\\pi}{c}\\vec{\\mathbf{j}} \\nabla \\cdot \\vec{\\mathbf{E}} &amp; = 4 \\pi \\rho \\\\\n\n\\nabla \\times \\vec{\\mathbf{E}}\\, +\\, \\frac1c\\, \\frac{\\partial\\vec{\\mathbf{B}}}{\\partial t} &amp; = \\vec{\\mathbf{0}} \\\\\n\n\\nabla \\cdot \\vec{\\mathbf{B}} &amp; = 0\n\n\\end{array}" data-type="block-math"></div>' '<div data-latex="\\begin{array}{c}\n\\nabla \\times \\vec{\\mathbf{B}} -\\, \\frac1c\\, \\frac{\\partial\\vec{\\mathbf{E}}}{\\partial t} &amp;\n= \\frac{4\\pi}{c}\\vec{\\mathbf{j}} \\nabla \\cdot \\vec{\\mathbf{E}} &amp; = 4 \\pi \\rho \\\\\n\n\\nabla \\times \\vec{\\mathbf{E}}\\, +\\, \\frac1c\\, \\frac{\\partial\\vec{\\mathbf{B}}}{\\partial t} &amp; = \\vec{\\mathbf{0}} \\\\\n\n\\nabla \\cdot \\vec{\\mathbf{B}} &amp; = 0\n\n\\end{array}" data-type="block-math"></div>'
) )
@ -189,7 +200,7 @@ describe('markdownConverter', () => {
it('should convert task list syntax to proper HTML', () => { it('should convert task list syntax to proper HTML', () => {
const markdown = '- [ ] abcd\n\n- [x] efgh\n\n' 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="taskList"')
expect(result).toContain('data-type="taskItem"') expect(result).toContain('data-type="taskItem"')
expect(result).toContain('data-checked="false"') expect(result).toContain('data-checked="false"')
@ -202,7 +213,7 @@ describe('markdownConverter', () => {
it('should convert mixed task list with checked and unchecked items', () => { 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 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('data-type="taskList"')
expect(result).toContain('First task') expect(result).toContain('First task')
expect(result).toContain('Second task') expect(result).toContain('Second task')
@ -213,14 +224,14 @@ describe('markdownConverter', () => {
it('should NOT convert standalone task syntax to task list', () => { it('should NOT convert standalone task syntax to task list', () => {
const markdown = '[x] abcd' const markdown = '[x] abcd'
const result = markdownToHtml(markdown) const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toContain('<p>[x] abcd</p>') expect(result).toContain('<p>[x] abcd</p>')
expect(result).not.toContain('data-type="taskList"') expect(result).not.toContain('data-type="taskList"')
}) })
it('should handle regular list items alongside task lists', () => { it('should handle regular list items alongside task lists', () => {
const markdown = '- Regular item\n\n- [ ] Task item\n\n- Another regular item' 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('data-type="taskList"')
expect(result).toContain('Regular item') expect(result).toContain('Regular item')
expect(result).toContain('Task item') expect(result).toContain('Task item')
@ -241,25 +252,25 @@ describe('markdownConverter', () => {
const markdown = `# 🌠 Screenshot const markdown = `# 🌠 Screenshot
![](https://example.com/image.png)` ![](https://example.com/image.png)`
const result = markdownToHtml(markdown) const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toBe('<h1>🌠 Screenshot</h1>\n<p><img src="https://example.com/image.png" alt="" /></p>\n') expect(result).toBe('<h1>🌠 Screenshot</h1>\n<p><img src="https://example.com/image.png" alt="" /></p>\n')
}) })
it('should handle heading and paragraph', () => { it('should handle heading and paragraph', () => {
const markdown = '# Hello\n\nHello' const markdown = '# Hello\n\nHello'
const result = markdownToHtml(markdown) const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toBe('<h1>Hello</h1>\n<p>Hello</p>\n') expect(result).toBe('<h1>Hello</h1>\n<p>Hello</p>\n')
}) })
it('should convert code block to HTML', () => { it('should convert code block to HTML', () => {
const markdown = '```\nconsole.log("Hello, world!");\n```' const markdown = '```\nconsole.log("Hello, world!");\n```'
const result = markdownToHtml(markdown) const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toBe('<pre><code>console.log(&#x22;Hello, world!&#x22;);\n</code></pre>') expect(result).toBe('<pre><code>console.log(&#x22;Hello, world!&#x22;);\n</code></pre>')
}) })
it('should convert code block with language to HTML', () => { it('should convert code block with language to HTML', () => {
const markdown = '```javascript\nconsole.log("Hello, world!");\n```' const markdown = '```javascript\nconsole.log("Hello, world!");\n```'
const result = markdownToHtml(markdown) const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toBe( expect(result).toBe(
'<pre><code class="language-javascript">console.log(&#x22;Hello, world!&#x22;);\n</code></pre>' '<pre><code class="language-javascript">console.log(&#x22;Hello, world!&#x22;);\n</code></pre>'
) )
@ -267,7 +278,7 @@ describe('markdownConverter', () => {
it('should convert table to HTML', () => { it('should convert table to HTML', () => {
const markdown = '| f | | |\n| --- | --- | --- |\n| | f | |\n| | | f |' const markdown = '| f | | |\n| --- | --- | --- |\n| | f | |\n| | | f |'
const result = markdownToHtml(markdown) const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toBe( expect(result).toBe(
'<table>\n<thead>\n<tr>\n<th>f</th>\n<th></th>\n<th></th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td></td>\n<td>f</td>\n<td></td>\n</tr>\n<tr>\n<td></td>\n<td></td>\n<td>f</td>\n</tr>\n</tbody>\n</table>\n' '<table>\n<thead>\n<tr>\n<th>f</th>\n<th></th>\n<th></th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td></td>\n<td>f</td>\n<td></td>\n</tr>\n<tr>\n<td></td>\n<td></td>\n<td>f</td>\n</tr>\n</tbody>\n</table>\n'
) )
@ -275,7 +286,7 @@ describe('markdownConverter', () => {
it('should escape XML-like tags in code blocks', () => { it('should escape XML-like tags in code blocks', () => {
const markdown = '```jsx\nconst component = <><div>content</div></>\n```' const markdown = '```jsx\nconst component = <><div>content</div></>\n```'
const result = markdownToHtml(markdown) const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toBe( expect(result).toBe(
'<pre><code class="language-jsx">const component = &#x3C;&#x3E;&#x3C;div&#x3E;content&#x3C;/div&#x3E;&#x3C;/&#x3E;\n</code></pre>' '<pre><code class="language-jsx">const component = &#x3C;&#x3E;&#x3C;div&#x3E;content&#x3C;/div&#x3E;&#x3C;/&#x3E;\n</code></pre>'
) )
@ -283,13 +294,13 @@ describe('markdownConverter', () => {
it('should escape XML-like tags in inline code', () => { it('should escape XML-like tags in inline code', () => {
const markdown = 'Use `<>` for fragments' const markdown = 'Use `<>` for fragments'
const result = markdownToHtml(markdown) const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toBe('<p>Use <code>&#x3C;&#x3E;</code> for fragments</p>\n') expect(result).toBe('<p>Use <code>&#x3C;&#x3E;</code> for fragments</p>\n')
}) })
it('shoud convert XML-like tags in paragraph', () => { it('shoud convert XML-like tags in paragraph', () => {
const markdown = '<abc></abc>' const markdown = '<abc></abc>'
const result = markdownToHtml(markdown) const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toBe('<p><abc></abc></p>\n') expect(result).toBe('<p><abc></abc></p>\n')
}) })
}) })
@ -297,7 +308,7 @@ describe('markdownConverter', () => {
describe('Task List with Labels', () => { describe('Task List with Labels', () => {
it('should wrap task items with labels when label option is true', () => { it('should wrap task items with labels when label option is true', () => {
const markdown = '- [ ] abcd\n\n- [x] efgh' const markdown = '- [ ] abcd\n\n- [x] efgh'
const result = markdownToHtml(markdown) const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toBe( expect(result).toBe(
'<ul data-type="taskList" class="task-list">\n<li data-type="taskItem" class="task-list-item" data-checked="false">\n<p><label><input type="checkbox" disabled> abcd</label></p>\n</li>\n<li data-type="taskItem" class="task-list-item" data-checked="true">\n<p><label><input type="checkbox" checked disabled> efgh</label></p>\n</li>\n</ul>\n' '<ul data-type="taskList" class="task-list">\n<li data-type="taskItem" class="task-list-item" data-checked="false">\n<p><label><input type="checkbox" disabled> abcd</label></p>\n</li>\n<li data-type="taskItem" class="task-list-item" data-checked="true">\n<p><label><input type="checkbox" checked disabled> efgh</label></p>\n</li>\n</ul>\n'
) )
@ -317,7 +328,7 @@ describe('markdownConverter', () => {
const originalHtml = const originalHtml =
'<ul data-type="taskList" class="task-list"><li data-type="taskItem" class="task-list-item" data-checked="false"><label><input type="checkbox" disabled></label><div><p></p></div></li></ul>' '<ul data-type="taskList" class="task-list"><li data-type="taskItem" class="task-list-item" data-checked="false"><label><input type="checkbox" disabled></label><div><p></p></div></li></ul>'
const markdown = htmlToMarkdown(originalHtml) const markdown = htmlToMarkdown(originalHtml)
const html = markdownToHtml(markdown) const html = stripLineNumbers(markdownToHtml(markdown))
expect(html).toBe( expect(html).toBe(
'<ul data-type="taskList" class="task-list">\n<li data-type="taskItem" class="task-list-item" data-checked="false"><label><input type="checkbox" disabled></label><div><p></p></div></li>\n</ul>\n' '<ul data-type="taskList" class="task-list">\n<li data-type="taskItem" class="task-list-item" data-checked="false"><label><input type="checkbox" disabled></label><div><p></p></div></li>\n</ul>\n'
@ -328,7 +339,7 @@ describe('markdownConverter', () => {
const originalHtml = const originalHtml =
'<ul data-type="taskList" class="task-list">\n<li data-type="taskItem" class="task-list-item" data-checked="false">\n<label><input type="checkbox" disabled></label><div><p>123</p></div>\n</li>\n<li data-type="taskItem" class="task-list-item" data-checked="false">\n<label><input type="checkbox" disabled></label><div><p></p></div>\n</li>\n</ul>\n' '<ul data-type="taskList" class="task-list">\n<li data-type="taskItem" class="task-list-item" data-checked="false">\n<label><input type="checkbox" disabled></label><div><p>123</p></div>\n</li>\n<li data-type="taskItem" class="task-list-item" data-checked="false">\n<label><input type="checkbox" disabled></label><div><p></p></div>\n</li>\n</ul>\n'
const markdown = htmlToMarkdown(originalHtml) const markdown = htmlToMarkdown(originalHtml)
const html = markdownToHtml(markdown) const html = stripLineNumbers(markdownToHtml(markdown))
expect(html).toBe(originalHtml) expect(html).toBe(originalHtml)
}) })
@ -383,7 +394,7 @@ describe('markdownConverter', () => {
describe('markdown image', () => { describe('markdown image', () => {
it('should convert markdown image to HTML img tag', () => { it('should convert markdown image to HTML img tag', () => {
const markdown = '![foo](train.jpg)' const markdown = '![foo](train.jpg)'
const result = markdownToHtml(markdown) const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toBe('<p><img src="train.jpg" alt="foo" /></p>\n') expect(result).toBe('<p><img src="train.jpg" alt="foo" /></p>\n')
}) })
it('should convert markdown image with file:// protocol to HTML img tag', () => { 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', () => { it('should handle hardbreak with backslash followed by indented text', () => {
const markdown = 'Text with \\\n indentation \\\nand without indentation' const markdown = 'Text with \\\n indentation \\\nand without indentation'
const result = markdownToHtml(markdown) const result = stripLineNumbers(markdownToHtml(markdown))
expect(result).toBe('<p>Text with <br />\nindentation <br />\nand without indentation</p>\n') expect(result).toBe('<p>Text with <br />\nindentation <br />\nand without indentation</p>\n')
}) })
@ -454,7 +465,7 @@ describe('markdownConverter', () => {
it('should preserve custom XML tags mixed with regular markdown', () => { it('should preserve custom XML tags mixed with regular markdown', () => {
const markdown = '# Heading\n\n<custom-widget id="widget1">Widget content</custom-widget>\n\n**Bold text**' const markdown = '# Heading\n\n<custom-widget id="widget1">Widget content</custom-widget>\n\n**Bold text**'
const html = markdownToHtml(markdown) const html = stripLineNumbers(markdownToHtml(markdown))
const backToMarkdown = htmlToMarkdown(html) const backToMarkdown = htmlToMarkdown(html)
expect(html).toContain('<h1>Heading</h1>') expect(html).toContain('<h1>Heading</h1>')
@ -470,7 +481,7 @@ describe('markdownConverter', () => {
it('should not add unwanted line breaks during simple text typing', () => { it('should not add unwanted line breaks during simple text typing', () => {
const html = '<p>Hello world</p>' const html = '<p>Hello world</p>'
const markdown = htmlToMarkdown(html) const markdown = htmlToMarkdown(html)
const backToHtml = markdownToHtml(markdown) const backToHtml = stripLineNumbers(markdownToHtml(markdown))
expect(markdown).toBe('Hello world') expect(markdown).toBe('Hello world')
expect(backToHtml).toBe('<p>Hello world</p>\n') expect(backToHtml).toBe('<p>Hello world</p>\n')
@ -479,7 +490,7 @@ describe('markdownConverter', () => {
it('should preserve simple paragraph structure during round-trip conversion', () => { it('should preserve simple paragraph structure during round-trip conversion', () => {
const originalHtml = '<p>This is a simple paragraph being typed</p>' const originalHtml = '<p>This is a simple paragraph being typed</p>'
const markdown = htmlToMarkdown(originalHtml) const markdown = htmlToMarkdown(originalHtml)
const backToHtml = markdownToHtml(markdown) const backToHtml = stripLineNumbers(markdownToHtml(markdown))
expect(markdown).toBe('This is a simple paragraph being typed') expect(markdown).toBe('This is a simple paragraph being typed')
expect(backToHtml).toBe('<p>This is a simple paragraph being typed</p>\n') expect(backToHtml).toBe('<p>This is a simple paragraph being typed</p>\n')
}) })
@ -520,4 +531,24 @@ cssclasses:
expect(backToMarkdown).toBe(markdown) 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(`<p ${MARKDOWN_SOURCE_LINE_ATTR}="1">First paragraph</p>`)
expect(result).toContain(`<p ${MARKDOWN_SOURCE_LINE_ATTR}="3">Second paragraph</p>`)
expect(result).toContain(`<p ${MARKDOWN_SOURCE_LINE_ATTR}="5">Third paragraph</p>`)
})
it('should inject line numbers into mixed content', () => {
const markdown = 'Text\n\n- List\n\n> Quote'
const result = markdownToHtml(markdown)
expect(result).toContain(`<p ${MARKDOWN_SOURCE_LINE_ATTR}="1">Text</p>`)
expect(result).toContain(`<ul ${MARKDOWN_SOURCE_LINE_ATTR}="3">`)
expect(result).toContain(`<li ${MARKDOWN_SOURCE_LINE_ATTR}="3">List</li>`)
expect(result).toContain(`<blockquote ${MARKDOWN_SOURCE_LINE_ATTR}="5">`)
expect(result).toContain(`<p ${MARKDOWN_SOURCE_LINE_ATTR}="5">Quote</p>`)
})
})
}) })

View File

@ -1,4 +1,5 @@
import { loggerService } from '@logger' import { loggerService } from '@logger'
import { MARKDOWN_SOURCE_LINE_ATTR } from '@renderer/components/RichEditor/constants'
import { TurndownPlugin } from '@truto/turndown-plugin-gfm' import { TurndownPlugin } from '@truto/turndown-plugin-gfm'
import he from 'he' import he from 'he'
import htmlTags, { type HtmlTags } from 'html-tags' import htmlTags, { type HtmlTags } from 'html-tags'
@ -85,12 +86,57 @@ const md = new MarkdownIt({
typographer: false // Enable smartypants and other sweet transforms 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., <hr />) and opening tags (e.g., <p>)
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 // Override the code_block and code_inline renderers to properly escape HTML entities
md.renderer.rules.code_block = function (tokens, idx) { md.renderer.rules.code_block = function (tokens, idx) {
const token = tokens[idx] const token = tokens[idx]
const langName = token.info ? ` class="language-${token.info.trim()}"` : '' const langName = token.info ? ` class="language-${token.info.trim()}"` : ''
const escapedContent = he.encode(token.content, { useNamedReferences: false }) const escapedContent = he.encode(token.content, { useNamedReferences: false })
return `<pre><code${langName}>${escapedContent}</code></pre>` let html = `<pre><code${langName}>${escapedContent}</code></pre>`
html = injectLineNumber(token, html)
return html
} }
md.renderer.rules.code_inline = function (tokens, idx) { md.renderer.rules.code_inline = function (tokens, idx) {
@ -103,7 +149,9 @@ md.renderer.rules.fence = function (tokens, idx) {
const token = tokens[idx] const token = tokens[idx]
const langName = token.info ? ` class="language-${token.info.trim()}"` : '' const langName = token.info ? ` class="language-${token.info.trim()}"` : ''
const escapedContent = he.encode(token.content, { useNamedReferences: false }) const escapedContent = he.encode(token.content, { useNamedReferences: false })
return `<pre><code${langName}>${escapedContent}</code></pre>` let html = `<pre><code${langName}>${escapedContent}</code></pre>`
html = injectLineNumber(token, html)
return html
} }
// Custom task list plugin for markdown-it // Custom task list plugin for markdown-it
@ -305,8 +353,11 @@ function yamlFrontMatterPlugin(md: MarkdownIt) {
// Renderer: output YAML front matter as special HTML element // Renderer: output YAML front matter as special HTML element
md.renderer.rules.yaml_front_matter = (tokens: Array<{ content?: string }>, idx: number): string => { md.renderer.rules.yaml_front_matter = (tokens: Array<{ content?: string }>, idx: number): string => {
const content = tokens[idx]?.content ?? '' const token = tokens[idx]
return `<div data-type="yaml-front-matter" data-content="${he.encode(content)}">${content}</div>` const content = token?.content ?? ''
let html = `<div data-type="yaml-front-matter" data-content="${he.encode(content)}">${content}</div>`
html = injectLineNumber(token, html)
return html
} }
} }
@ -408,9 +459,12 @@ function tipTapKatexPlugin(md: MarkdownIt) {
// 2) Renderer: output TipTap-friendly container // 2) Renderer: output TipTap-friendly container
md.renderer.rules.math_block = (tokens: Array<{ content?: string }>, idx: number): string => { 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 }) const latexEscaped = he.encode(content, { useNamedReferences: true })
return `<div data-latex="${latexEscaped}" data-type="block-math"></div>` let html = `<div data-latex="${latexEscaped}" data-type="block-math"></div>`
html = injectLineNumber(token, html)
return html
} }
// 3) Inline parser: recognize $...$ on a single line as inline math // 3) Inline parser: recognize $...$ on a single line as inline math