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 { 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]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
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 { 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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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": "应用",
|
||||||
|
|||||||
@ -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": "應用",
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
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',
|
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',
|
||||||
|
|||||||
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 { 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} &\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>'
|
'<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', () => {
|
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
|
||||||
|
|
||||||
`
|
`
|
||||||
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("Hello, world!");\n</code></pre>')
|
expect(result).toBe('<pre><code>console.log("Hello, world!");\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("Hello, world!");\n</code></pre>'
|
'<pre><code class="language-javascript">console.log("Hello, world!");\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 = <><div>content</div></>\n</code></pre>'
|
'<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', () => {
|
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><></code> for fragments</p>\n')
|
expect(result).toBe('<p>Use <code><></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 = ''
|
const markdown = ''
|
||||||
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>`)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user