mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-19 14:41:24 +08:00
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:
parent
fb680ce764
commit
1f7d2fa93f
@ -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<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]
|
||||
)
|
||||
}
|
||||
|
||||
@ -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<boolean>()
|
||||
|
||||
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 (
|
||||
|
||||
48
src/renderer/src/components/HighlightText.tsx
Normal file
48
src/renderer/src/components/HighlightText.tsx
Normal 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)
|
||||
2
src/renderer/src/components/RichEditor/constants.ts
Normal file
2
src/renderer/src/components/RichEditor/constants.ts
Normal 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'
|
||||
@ -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<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 = ({
|
||||
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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "应用",
|
||||
|
||||
@ -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": "應用",
|
||||
|
||||
@ -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<RichEditorRef | null>
|
||||
codeEditorRef: RefObject<CodeEditorHandles | null>
|
||||
onMarkdownChange: (content: string) => void
|
||||
}
|
||||
|
||||
const NotesEditor: FC<NotesEditorProps> = 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<NotesEditorProps> = memo(
|
||||
{tmpViewMode === 'source' ? (
|
||||
<SourceEditorWrapper isFullWidth={settings.isFullWidth} fontSize={settings.fontSize}>
|
||||
<CodeEditor
|
||||
ref={codeEditorRef}
|
||||
value={currentContent}
|
||||
language="markdown"
|
||||
onChange={onMarkdownChange}
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
||||
import { CodeEditorHandles } from '@renderer/components/CodeEditor'
|
||||
import { RichEditorRef } from '@renderer/components/RichEditor/types'
|
||||
import { useActiveNode, useFileContent, useFileContentSync } from '@renderer/hooks/useNotesQuery'
|
||||
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
||||
import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import {
|
||||
addDir,
|
||||
addNote,
|
||||
@ -51,6 +53,7 @@ const logger = loggerService.withContext('NotesPage')
|
||||
|
||||
const NotesPage: FC = () => {
|
||||
const editorRef = useRef<RichEditorRef>(null)
|
||||
const codeEditorRef = useRef<CodeEditorHandles>(null)
|
||||
const { t } = useTranslation()
|
||||
const { showWorkspace } = useShowWorkspace()
|
||||
const dispatch = useAppDispatch()
|
||||
@ -76,6 +79,7 @@ const NotesPage: FC = () => {
|
||||
const lastFilePathRef = useRef<string | undefined>(undefined)
|
||||
const isRenamingRef = useRef(false)
|
||||
const isCreatingNoteRef = useRef(false)
|
||||
const pendingScrollRef = useRef<{ lineNumber: number; lineContent?: string } | null>(null)
|
||||
|
||||
const activeFilePathRef = useRef<string | undefined>(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 (
|
||||
<Container id="notes-page">
|
||||
<Navbar>
|
||||
@ -800,6 +877,7 @@ const NotesPage: FC = () => {
|
||||
tokenCount={tokenCount}
|
||||
onMarkdownChange={handleMarkdownChange}
|
||||
editorRef={editorRef}
|
||||
codeEditorRef={codeEditorRef}
|
||||
/>
|
||||
</EditorWrapper>
|
||||
</ContentContainer>
|
||||
|
||||
@ -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<TreeNodeProps>(
|
||||
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<TreeNodeProps>(
|
||||
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 (
|
||||
<div key={node.id}>
|
||||
<Dropdown
|
||||
@ -182,13 +242,55 @@ const TreeNode = memo<TreeNodeProps>(
|
||||
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>
|
||||
</TreeNodeContainer>
|
||||
</div>
|
||||
</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 && (
|
||||
<div>
|
||||
{node.children!.map((child) => (
|
||||
@ -257,6 +359,31 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
const [openDropdownKey, setOpenDropdownKey] = useState<string | null>(null)
|
||||
const dragNodeRef = useRef<HTMLDivElement | null>(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({
|
||||
onSave: (newName: string) => {
|
||||
@ -514,6 +641,25 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
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<NotesSidebarProps> = ({
|
||||
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<NotesSidebarProps> = ({
|
||||
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<NotesSidebarProps> = ({
|
||||
|
||||
// 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<NotesSidebarProps> = ({
|
||||
/>
|
||||
|
||||
<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 ? (
|
||||
<Dropdown
|
||||
menu={{ items: getEmptyAreaMenuItems() }}
|
||||
@ -967,6 +1136,8 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
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)
|
||||
|
||||
159
src/renderer/src/pages/notes/hooks/useFullTextSearch.ts
Normal file
159
src/renderer/src/pages/notes/hooks/useFullTextSearch.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
|
||||
262
src/renderer/src/services/NotesSearchService.ts
Normal file
262
src/renderer/src/services/NotesSearchService.ts
Normal 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
|
||||
}
|
||||
@ -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 <br> to <br>', () => {
|
||||
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')
|
||||
})
|
||||
|
||||
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('<ul>')
|
||||
expect(result).toContain('<li>')
|
||||
@ -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('<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', () => {
|
||||
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(
|
||||
'<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)', () => {
|
||||
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')
|
||||
})
|
||||
|
||||
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(
|
||||
'<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', () => {
|
||||
const markdown = '# Hello World'
|
||||
const result = markdownToHtml(markdown)
|
||||
const result = stripLineNumbers(markdownToHtml(markdown))
|
||||
expect(result).toContain('<h1>Hello World</h1>')
|
||||
})
|
||||
|
||||
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('<div data-latex="a+b+c" data-type="block-math"></div>')
|
||||
})
|
||||
|
||||
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('<span data-latex="a+b+c" data-type="inline-math"></span>')
|
||||
})
|
||||
|
||||
@ -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(
|
||||
'<div data-latex="\\begin{array}{c}\n\\nabla \\times \\vec{\\mathbf{B}} -\\, \\frac1c\\, \\frac{\\partial\\vec{\\mathbf{E}}}{\\partial t} &\n= \\frac{4\\pi}{c}\\vec{\\mathbf{j}} \\nabla \\cdot \\vec{\\mathbf{E}} & = 4 \\pi \\rho \\\\\n\n\\nabla \\times \\vec{\\mathbf{E}}\\, +\\, \\frac1c\\, \\frac{\\partial\\vec{\\mathbf{B}}}{\\partial t} & = \\vec{\\mathbf{0}} \\\\\n\n\\nabla \\cdot \\vec{\\mathbf{B}} & = 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', () => {
|
||||
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('<p>[x] abcd</p>')
|
||||
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
|
||||
|
||||
`
|
||||
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')
|
||||
})
|
||||
|
||||
it('should handle heading and paragraph', () => {
|
||||
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')
|
||||
})
|
||||
|
||||
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('<pre><code>console.log("Hello, world!");\n</code></pre>')
|
||||
})
|
||||
|
||||
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(
|
||||
'<pre><code class="language-javascript">console.log("Hello, world!");\n</code></pre>'
|
||||
)
|
||||
@ -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(
|
||||
'<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', () => {
|
||||
const markdown = '```jsx\nconst component = <><div>content</div></>\n```'
|
||||
const result = markdownToHtml(markdown)
|
||||
const result = stripLineNumbers(markdownToHtml(markdown))
|
||||
expect(result).toBe(
|
||||
'<pre><code class="language-jsx">const component = <><div>content</div></>\n</code></pre>'
|
||||
)
|
||||
@ -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('<p>Use <code><></code> for fragments</p>\n')
|
||||
})
|
||||
|
||||
it('shoud convert XML-like tags in paragraph', () => {
|
||||
const markdown = '<abc></abc>'
|
||||
const result = markdownToHtml(markdown)
|
||||
const result = stripLineNumbers(markdownToHtml(markdown))
|
||||
expect(result).toBe('<p><abc></abc></p>\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(
|
||||
'<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 =
|
||||
'<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 html = markdownToHtml(markdown)
|
||||
const html = stripLineNumbers(markdownToHtml(markdown))
|
||||
|
||||
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'
|
||||
@ -328,7 +339,7 @@ describe('markdownConverter', () => {
|
||||
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'
|
||||
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 = ''
|
||||
const result = markdownToHtml(markdown)
|
||||
const result = stripLineNumbers(markdownToHtml(markdown))
|
||||
expect(result).toBe('<p><img src="train.jpg" alt="foo" /></p>\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('<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', () => {
|
||||
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)
|
||||
|
||||
expect(html).toContain('<h1>Heading</h1>')
|
||||
@ -470,7 +481,7 @@ describe('markdownConverter', () => {
|
||||
it('should not add unwanted line breaks during simple text typing', () => {
|
||||
const html = '<p>Hello world</p>'
|
||||
const markdown = htmlToMarkdown(html)
|
||||
const backToHtml = markdownToHtml(markdown)
|
||||
const backToHtml = stripLineNumbers(markdownToHtml(markdown))
|
||||
|
||||
expect(markdown).toBe('Hello world')
|
||||
expect(backToHtml).toBe('<p>Hello world</p>\n')
|
||||
@ -479,7 +490,7 @@ describe('markdownConverter', () => {
|
||||
it('should preserve simple paragraph structure during round-trip conversion', () => {
|
||||
const originalHtml = '<p>This is a simple paragraph being typed</p>'
|
||||
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('<p>This is a simple paragraph being typed</p>\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(`<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>`)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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., <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
|
||||
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 `<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) {
|
||||
@ -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 `<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
|
||||
@ -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 `<div data-type="yaml-front-matter" data-content="${he.encode(content)}">${content}</div>`
|
||||
const token = tokens[idx]
|
||||
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
|
||||
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 `<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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user