feat: add html-tags and htmlparser2 dependencies; enhance CodeViewer and RichEditor components (#9757)

* feat: add html-tags and htmlparser2 dependencies; enhance CodeViewer and RichEditor components

* fix(NotesPage): prevent unnecessary state clearing when notesTree is empty

* feat(NotesPage): enhance note saving functionality to include file path management

* style: refine button and border styles across components for improved aesthetics

- Updated ToolbarButton styles to simplify background and hover effects.
- Adjusted border styles in NotesEditor, NotesSidebar, and NotesSidebarHeader for a more consistent look.
- Enhanced overall UI by reducing border thickness in various components.

* style: add bottom spacer to richtext component for improved viewport padding

* style: ensure drag handles and plus buttons are interactive in richtext component

* feat(RichEditor): add conditional focus behavior based on text length in rich editor

---------

Co-authored-by: kangfenmao <kangfenmao@qq.com>
This commit is contained in:
SuYao 2025-09-01 17:13:31 +08:00 committed by GitHub
parent 197bae6065
commit f085f6c7bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 261 additions and 194 deletions

View File

@ -229,7 +229,9 @@
"fs-extra": "^11.2.0", "fs-extra": "^11.2.0",
"google-auth-library": "^9.15.1", "google-auth-library": "^9.15.1",
"he": "^1.2.0", "he": "^1.2.0",
"html-tags": "^5.1.0",
"html-to-image": "^1.11.13", "html-to-image": "^1.11.13",
"htmlparser2": "^10.0.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"i18next": "^23.11.5", "i18next": "^23.11.5",
"iconv-lite": "^0.6.3", "iconv-lite": "^0.6.3",

View File

@ -148,6 +148,12 @@
left: 0; left: 0;
right: 0; right: 0;
} }
/* Ensure drag handles and plus buttons remain interactive */
.drag-handle,
.plus-button {
pointer-events: auto;
}
} }
/* Show placeholder only when focused or when it's the only empty node */ /* Show placeholder only when focused or when it's the only empty node */
@ -471,6 +477,14 @@
align-items: center; align-items: center;
font-size: 1.2rem; font-size: 1.2rem;
} }
/* Bottom spacer to create viewport padding */
&::after {
content: '';
display: block;
height: 50px;
pointer-events: none;
}
} }
// Code block wrapper and header styles // Code block wrapper and header styles

View File

@ -17,6 +17,7 @@ interface CodeViewerProps {
wrapped?: boolean wrapped?: boolean
onHeightChange?: (scrollHeight: number) => void onHeightChange?: (scrollHeight: number) => void
className?: string className?: string
height?: string | number
} }
/** /**
@ -25,7 +26,7 @@ interface CodeViewerProps {
* - 使 * - 使
* - * -
*/ */
const CodeViewer = ({ children, language, expanded, wrapped, onHeightChange, className }: CodeViewerProps) => { const CodeViewer = ({ children, language, expanded, wrapped, onHeightChange, className, height }: CodeViewerProps) => {
const { codeShowLineNumbers, fontSize } = useSettings() const { codeShowLineNumbers, fontSize } = useSettings()
const { getShikiPreProperties, isShikiThemeDark } = useCodeStyle() const { getShikiPreProperties, isShikiThemeDark } = useCodeStyle()
const shikiThemeRef = useRef<HTMLDivElement>(null) const shikiThemeRef = useRef<HTMLDivElement>(null)
@ -104,18 +105,20 @@ const CodeViewer = ({ children, language, expanded, wrapped, onHeightChange, cla
}, [rawLines.length, onHeightChange]) }, [rawLines.length, onHeightChange])
return ( return (
<div ref={shikiThemeRef}> <div ref={shikiThemeRef} style={height ? { height } : undefined}>
<ScrollContainer <ScrollContainer
ref={scrollerRef} ref={scrollerRef}
className="shiki-scroller" className="shiki-scroller"
$wrap={wrapped} $wrap={wrapped}
$expanded={expanded} $expanded={expanded}
$lineHeight={estimateSize()} $lineHeight={estimateSize()}
$height={height}
style={ style={
{ {
'--gutter-width': `${gutterDigits}ch`, '--gutter-width': `${gutterDigits}ch`,
fontSize: `${fontSize - 1}px`, fontSize: `${fontSize - 1}px`,
maxHeight: expanded ? undefined : MAX_COLLAPSED_CODE_HEIGHT, maxHeight: expanded ? undefined : height ? undefined : MAX_COLLAPSED_CODE_HEIGHT,
height: height,
overflowY: expanded ? 'hidden' : 'auto' overflowY: expanded ? 'hidden' : 'auto'
} as React.CSSProperties } as React.CSSProperties
}> }>
@ -225,6 +228,7 @@ const ScrollContainer = styled.div<{
$wrap?: boolean $wrap?: boolean
$expanded?: boolean $expanded?: boolean
$lineHeight?: number $lineHeight?: number
$height?: string | number
}>` }>`
display: block; display: block;
overflow-x: auto; overflow-x: auto;

View File

@ -61,15 +61,13 @@ export const ToolbarButton = styled.button<{
height: 32px; height: 32px;
border: none; border: none;
border-radius: 4px; border-radius: 4px;
background: ${({ $active }) => ($active ? 'var(--color-primary)' : 'transparent')}; background: transparent;
color: ${({ $active, $disabled }) =>
$disabled ? 'var(--color-text-3)' : $active ? 'var(--color-white)' : 'var(--color-text)'};
cursor: ${({ $disabled }) => ($disabled ? 'not-allowed' : 'pointer')}; cursor: ${({ $disabled }) => ($disabled ? 'not-allowed' : 'pointer')};
transition: all 0.2s ease; transition: all 0.2s ease;
flex-shrink: 0; /* 防止按钮收缩 */ flex-shrink: 0; /* 防止按钮收缩 */
&:hover:not(:disabled) { &:hover:not(:disabled) {
background: ${({ $active }) => ($active ? 'var(--color-primary)' : 'var(--color-hover)')}; background: var(--color-hover);
} }
&:disabled { &:disabled {

View File

@ -1,6 +1,7 @@
import { Tooltip } from 'antd' import { Tooltip } from 'antd'
import type { TFunction } from 'i18next' import type { TFunction } from 'i18next'
import React, { useEffect, useState } from 'react' import { LucideProps } from 'lucide-react'
import React, { ForwardRefExoticComponent, RefAttributes, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { getCommandsByGroup } from './command' import { getCommandsByGroup } from './command'
@ -12,7 +13,7 @@ import type { FormattingCommand, FormattingState, ToolbarProps } from './types'
interface ToolbarItemInternal { interface ToolbarItemInternal {
id: string id: string
command?: FormattingCommand command?: FormattingCommand
icon?: React.ComponentType icon?: ForwardRefExoticComponent<Omit<LucideProps, 'ref'> & RefAttributes<SVGSVGElement>>
type?: 'divider' type?: 'divider'
handler?: () => void handler?: () => void
} }
@ -170,7 +171,7 @@ export const Toolbar: React.FC<ToolbarProps> = ({ editor, formattingState, onCom
disabled={isDisabled} disabled={isDisabled}
onClick={() => handleCommand(command)} onClick={() => handleCommand(command)}
data-testid={`toolbar-${command}`}> data-testid={`toolbar-${command}`}>
<Icon /> <Icon color={isActive ? 'var(--color-primary)' : 'var(--color-text)'} />
</ToolbarButton> </ToolbarButton>
) )

View File

@ -8,9 +8,7 @@ import {
htmlToMarkdown, htmlToMarkdown,
isMarkdownContent, isMarkdownContent,
markdownToHtml, markdownToHtml,
markdownToPreviewText, markdownToPreviewText
markdownToSafeHtml,
sanitizeHtml
} from '@renderer/utils/markdownConverter' } from '@renderer/utils/markdownConverter'
import type { Editor } from '@tiptap/core' import type { Editor } from '@tiptap/core'
import { TaskItem, TaskList } from '@tiptap/extension-list' import { TaskItem, TaskList } from '@tiptap/extension-list'
@ -135,7 +133,7 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
const html = useMemo(() => { const html = useMemo(() => {
if (!markdown) return '' if (!markdown) return ''
return markdownToSafeHtml(markdown) return markdownToHtml(markdown)
}, [markdown]) }, [markdown])
const previewText = useMemo(() => { const previewText = useMemo(() => {
@ -423,8 +421,7 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
onContentChange?.(content) onContentChange?.(content)
if (onHtmlChange) { if (onHtmlChange) {
const safeHtml = sanitizeHtml(htmlContent) onHtmlChange(htmlContent)
onHtmlChange(safeHtml)
} }
} catch (error) { } catch (error) {
logger.error('Error converting HTML to markdown:', error as Error) logger.error('Error converting HTML to markdown:', error as Error)
@ -502,7 +499,10 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
try { try {
setTimeout(() => { setTimeout(() => {
if (editor && !editor.isDestroyed) { if (editor && !editor.isDestroyed) {
editor.commands.focus('end') const isLong = editor.getText().length > 2000
if (!isLong) {
editor.commands.focus('end')
}
} }
}, 0) }, 0)
} catch (error) { } catch (error) {
@ -724,7 +724,7 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
setMarkdownState(content) setMarkdownState(content)
onChange?.(content) onChange?.(content)
const convertedHtml = markdownToSafeHtml(content) const convertedHtml = markdownToHtml(content)
editor.commands.setContent(convertedHtml) editor.commands.setContent(convertedHtml)
@ -771,7 +771,7 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
const toSafeHtml = useCallback((content: string): string => { const toSafeHtml = useCallback((content: string): string => {
try { try {
return markdownToSafeHtml(content) return markdownToHtml(content)
} catch (error) { } catch (error) {
logger.error('Error converting markdown to safe HTML:', error as Error) logger.error('Error converting markdown to safe HTML:', error as Error)
return '' return ''

View File

@ -109,7 +109,9 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent }) => {
}, [activeNode, notesTree]) }, [activeNode, notesTree])
return ( return (
<NavbarHeader className="home-navbar" style={{ justifyContent: 'flex-start' }}> <NavbarHeader
className="home-navbar"
style={{ justifyContent: 'flex-start', borderBottom: '0.5px solid var(--color-border)' }}>
<HStack alignItems="center" flex="0 0 auto"> <HStack alignItems="center" flex="0 0 auto">
{showWorkspace && ( {showWorkspace && (
<Tooltip title={t('navbar.hide_sidebar')} mouseEnterDelay={0.8}> <Tooltip title={t('navbar.hide_sidebar')} mouseEnterDelay={0.8}>

View File

@ -145,6 +145,7 @@ const RichEditorContainer = styled.div`
.notes-rich-editor { .notes-rich-editor {
border: none; border: none;
border-radius: 0;
flex: 1; flex: 1;
background: transparent; background: transparent;
@ -173,7 +174,7 @@ const RichEditorContainer = styled.div`
const BottomPanel = styled.div` const BottomPanel = styled.div`
padding: 8px 16px; padding: 8px 16px;
border-top: 1px solid var(--color-border); border-top: 0.5px solid var(--color-border);
background: var(--color-background-soft); background: var(--color-background-soft);
flex-shrink: 0; flex-shrink: 0;
height: 48px; height: 48px;

View File

@ -53,6 +53,7 @@ const NotesPage: FC = () => {
const isSyncingTreeRef = useRef(false) const isSyncingTreeRef = useRef(false)
const isEditorInitialized = useRef(false) const isEditorInitialized = useRef(false)
const lastContentRef = useRef<string>('') const lastContentRef = useRef<string>('')
const lastFilePathRef = useRef<string | undefined>(undefined)
const isInitialSortApplied = useRef(false) const isInitialSortApplied = useRef(false)
const isRenamingRef = useRef(false) const isRenamingRef = useRef(false)
const isCreatingNoteRef = useRef(false) const isCreatingNoteRef = useRef(false)
@ -82,13 +83,14 @@ const NotesPage: FC = () => {
// 保存当前笔记内容 // 保存当前笔记内容
const saveCurrentNote = useCallback( const saveCurrentNote = useCallback(
async (content: string) => { async (content: string, filePath?: string) => {
if (!activeFilePath || content === currentContent) return const targetPath = filePath || activeFilePath
if (!targetPath || content === currentContent) return
try { try {
await window.api.file.write(activeFilePath, content) await window.api.file.write(targetPath, content)
// 保存后立即刷新缓存,确保下次读取时获取最新内容 // 保存后立即刷新缓存,确保下次读取时获取最新内容
invalidateFileContent(activeFilePath) invalidateFileContent(targetPath)
} catch (error) { } catch (error) {
logger.error('Failed to save note:', error as Error) logger.error('Failed to save note:', error as Error)
} }
@ -99,19 +101,22 @@ const NotesPage: FC = () => {
// 防抖保存函数,在停止输入后才保存,避免输入过程中的文件写入 // 防抖保存函数,在停止输入后才保存,避免输入过程中的文件写入
const debouncedSave = useMemo( const debouncedSave = useMemo(
() => () =>
debounce((content: string) => { debounce((content: string, filePath: string | undefined) => {
saveCurrentNote(content) saveCurrentNote(content, filePath)
}, 800), // 800ms防抖延迟 }, 800), // 800ms防抖延迟
[saveCurrentNote] [saveCurrentNote]
) )
const handleMarkdownChange = useCallback( const handleMarkdownChange = useCallback(
(newMarkdown: string) => { (newMarkdown: string) => {
// 记录最新内容,用于兜底保存 // 记录最新内容和文件路径,用于兜底保存
lastContentRef.current = newMarkdown lastContentRef.current = newMarkdown
debouncedSave(newMarkdown) lastFilePathRef.current = activeFilePath
// 捕获当前文件路径,避免在防抖执行时文件路径已改变的竞态条件
const currentFilePath = activeFilePath
debouncedSave(newMarkdown, currentFilePath)
}, },
[debouncedSave] [debouncedSave, activeFilePath]
) )
useEffect(() => { useEffect(() => {
@ -148,16 +153,16 @@ const NotesPage: FC = () => {
// 处理树同步时的状态管理 // 处理树同步时的状态管理
useEffect(() => { useEffect(() => {
if (notesTree.length === 0) return if (notesTree.length === 0) return
// 如果有activeFilePath但找不到对应节点清空选择 // 如果有activeFilePath但找不到对应节点清空选择
// 但要排除正在同步树结构、重命名或创建笔记的情况,避免在这些操作中误清空 // 但要排除正在同步树结构、重命名或创建笔记的情况,避免在这些操作中误清空
if ( const shouldClearPath =
activeFilePath && activeFilePath && !activeNode && !isSyncingTreeRef.current && !isRenamingRef.current && !isCreatingNoteRef.current
!activeNode &&
!isSyncingTreeRef.current && if (shouldClearPath) {
!isRenamingRef.current && logger.warn('Clearing activeFilePath - node not found in tree', {
!isCreatingNoteRef.current activeFilePath,
) { reason: 'Node not found in current tree'
})
dispatch(setActiveFilePath(undefined)) dispatch(setActiveFilePath(undefined))
} }
}, [notesTree, activeFilePath, activeNode, dispatch]) }, [notesTree, activeFilePath, activeNode, dispatch])
@ -257,8 +262,8 @@ const NotesPage: FC = () => {
}) })
// 如果有未保存的内容,立即保存 // 如果有未保存的内容,立即保存
if (lastContentRef.current && lastContentRef.current !== currentContent) { if (lastContentRef.current && lastContentRef.current !== currentContent && lastFilePathRef.current) {
saveCurrentNote(lastContentRef.current).catch((error) => { saveCurrentNote(lastContentRef.current, lastFilePathRef.current).catch((error) => {
logger.error('Emergency save failed:', error as Error) logger.error('Emergency save failed:', error as Error)
}) })
} }
@ -288,8 +293,8 @@ const NotesPage: FC = () => {
// 切换文件时重置编辑器初始化状态并兜底保存 // 切换文件时重置编辑器初始化状态并兜底保存
useEffect(() => { useEffect(() => {
if (lastContentRef.current && lastContentRef.current !== currentContent) { if (lastContentRef.current && lastContentRef.current !== currentContent && lastFilePathRef.current) {
saveCurrentNote(lastContentRef.current).catch((error) => { saveCurrentNote(lastContentRef.current, lastFilePathRef.current).catch((error) => {
logger.error('Emergency save before file switch failed:', error as Error) logger.error('Emergency save before file switch failed:', error as Error)
}) })
} }
@ -297,6 +302,7 @@ const NotesPage: FC = () => {
// 重置状态 // 重置状态
isEditorInitialized.current = false isEditorInitialized.current = false
lastContentRef.current = '' lastContentRef.current = ''
lastFilePathRef.current = undefined
}, [activeFilePath, currentContent, saveCurrentNote]) }, [activeFilePath, currentContent, saveCurrentNote])
// 获取目标文件夹路径(选中文件夹或根目录) // 获取目标文件夹路径(选中文件夹或根目录)
@ -425,6 +431,7 @@ const NotesPage: FC = () => {
if (node.type === 'file') { if (node.type === 'file') {
try { try {
dispatch(setActiveFilePath(node.externalPath)) dispatch(setActiveFilePath(node.externalPath))
invalidateFileContent(node.externalPath)
// 清除文件夹选择状态 // 清除文件夹选择状态
setSelectedFolderId(null) setSelectedFolderId(null)
} catch (error) { } catch (error) {
@ -435,7 +442,7 @@ const NotesPage: FC = () => {
await handleToggleExpanded(node.id) await handleToggleExpanded(node.id)
} }
}, },
[dispatch, handleToggleExpanded] [dispatch, handleToggleExpanded, invalidateFileContent]
) )
// 删除节点 // 删除节点

View File

@ -522,7 +522,7 @@ const SidebarContainer = styled.div`
width: 250px; width: 250px;
height: 100vh; height: 100vh;
background-color: var(--color-background); background-color: var(--color-background);
border-right: 1px solid var(--color-border); border-right: 0.5px solid var(--color-border);
border-top-left-radius: 10px; border-top-left-radius: 10px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -568,7 +568,7 @@ const TreeNodeContainer = styled.div<{
if (props.active) return 'var(--color-background-soft)' if (props.active) return 'var(--color-background-soft)'
return 'transparent' return 'transparent'
}}; }};
border: 1px solid border: 0.5px solid
${(props) => { ${(props) => {
if (props.isDragInside) return 'var(--color-primary)' if (props.isDragInside) return 'var(--color-primary)'
if (props.active) return 'var(--color-border)' if (props.active) return 'var(--color-border)'
@ -669,7 +669,7 @@ const EditInput = styled(Input)`
.ant-input { .ant-input {
font-size: 13px; font-size: 13px;
padding: 2px 6px; padding: 2px 6px;
border: 1px solid var(--color-primary); border: 0.5px solid var(--color-primary);
} }
` `

View File

@ -138,9 +138,10 @@ const NotesSidebarHeader: FC<NotesSidebarHeaderProps> = ({
const SidebarHeader = styled.div<{ isStarView?: boolean; isSearchView?: boolean }>` const SidebarHeader = styled.div<{ isStarView?: boolean; isSearchView?: boolean }>`
padding: 8px 12px; padding: 8px 12px;
border-bottom: 1px solid var(--color-border); border-bottom: 0.5px solid var(--color-border);
display: flex; display: flex;
justify-content: ${(props) => (props.isStarView || props.isSearchView ? 'flex-start' : 'center')}; justify-content: ${(props) => (props.isStarView || props.isSearchView ? 'flex-start' : 'center')};
height: var(--navbar-height);
` `
const HeaderActions = styled.div` const HeaderActions = styled.div`

View File

@ -1,9 +1,10 @@
import 'emoji-picker-element' import 'emoji-picker-element'
import { CloseCircleFilled } from '@ant-design/icons' import { CloseCircleFilled } from '@ant-design/icons'
import CodeEditor from '@renderer/components/CodeEditor'
import CodeViewer from '@renderer/components/CodeViewer'
import EmojiPicker from '@renderer/components/EmojiPicker' import EmojiPicker from '@renderer/components/EmojiPicker'
import { Box, HSpaceBetweenStack, HStack } from '@renderer/components/Layout' import { Box, HSpaceBetweenStack, HStack } from '@renderer/components/Layout'
import RichEditor from '@renderer/components/RichEditor'
import { RichEditorRef } from '@renderer/components/RichEditor/types' import { RichEditorRef } from '@renderer/components/RichEditor/types'
import { usePromptProcessor } from '@renderer/hooks/usePromptProcessor' import { usePromptProcessor } from '@renderer/hooks/usePromptProcessor'
import { estimateTextTokens } from '@renderer/services/TokenService' import { estimateTextTokens } from '@renderer/services/TokenService'
@ -47,9 +48,7 @@ const AssistantPromptSettings: React.FC<Props> = ({ assistant, updateAssistant }
}) })
const onUpdate = () => { const onUpdate = () => {
const text = editorRef.current?.getMarkdown() || '' const _assistant = { ...assistant, name: name.trim(), emoji, prompt }
setPrompt(text)
const _assistant = { ...assistant, name: name.trim(), emoji, prompt: text }
updateAssistant(_assistant) updateAssistant(_assistant)
window.message.success(t('common.saved')) window.message.success(t('common.saved'))
} }
@ -68,13 +67,6 @@ const AssistantPromptSettings: React.FC<Props> = ({ assistant, updateAssistant }
const promptVarsContent = <pre>{t('agents.add.prompt.variables.tip.content')}</pre> const promptVarsContent = <pre>{t('agents.add.prompt.variables.tip.content')}</pre>
const handleCommandsReady = (commandAPI: Pick<RichEditorRef, 'unregisterCommand'>) => {
const disabledCommands = ['image', 'inlineMath']
disabledCommands.forEach((commandId) => {
commandAPI.unregisterCommand(commandId)
})
}
return ( return (
<Container> <Container>
<Box mb={8} style={{ fontWeight: 'bold' }}> <Box mb={8} style={{ fontWeight: 'bold' }}>
@ -129,18 +121,20 @@ const AssistantPromptSettings: React.FC<Props> = ({ assistant, updateAssistant }
</HStack> </HStack>
<TextAreaContainer> <TextAreaContainer>
<RichEditorContainer> <RichEditorContainer>
<RichEditor {showPreview ? (
key={showPreview ? 'preview' : 'edit'} <CodeViewer children={processedPrompt} language="markdown" expanded={true} height="100%" />
ref={editorRef} ) : (
initialContent={showPreview ? processedPrompt : prompt} <CodeEditor
onCommandsReady={handleCommandsReady} value={prompt}
showToolbar={!showPreview} language="markdown"
editable={!showPreview} onChange={setPrompt}
showTableOfContents={false} height="100%"
enableContentSearch={false} expanded={false}
isFullWidth={true} style={{
className="prompt-rich-editor" height: '100%'
/> }}
/>
)}
</RichEditorContainer> </RichEditorContainer>
</TextAreaContainer> </TextAreaContainer>
<HSpaceBetweenStack width="100%" justifyContent="flex-end" mt="10px"> <HSpaceBetweenStack width="100%" justifyContent="flex-end" mt="10px">

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { htmlToMarkdown, markdownToHtml, markdownToSafeHtml, sanitizeHtml } from '../markdownConverter' import { htmlToMarkdown, markdownToHtml } from '../markdownConverter'
describe('markdownConverter', () => { describe('markdownConverter', () => {
describe('htmlToMarkdown', () => { describe('htmlToMarkdown', () => {
@ -294,33 +294,6 @@ describe('markdownConverter', () => {
}) })
}) })
describe('sanitizeHtml', () => {
it('should sanitize HTML content and remove scripts', () => {
const html = '<h1>Hello</h1><script>alert("xss")</script>'
const result = sanitizeHtml(html)
expect(result).toContain('<h1>Hello</h1>')
expect(result).not.toContain('<script>')
expect(result).not.toContain('alert')
})
it('should preserve task list HTML elements', () => {
const html =
'<ul data-type="taskList"><li data-type="taskItem" data-checked="true"><input type="checkbox" checked disabled> Task item</li></ul>'
const result = sanitizeHtml(html)
expect(result).toContain('data-type="taskList"')
expect(result).toContain('data-type="taskItem"')
expect(result).toContain('data-checked="true"')
expect(result).toContain('<input type="checkbox"')
expect(result).toContain('checked')
expect(result).toContain('disabled')
})
it('should handle empty HTML', () => {
const result = sanitizeHtml('')
expect(result).toBe('')
})
})
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'
@ -329,15 +302,6 @@ describe('markdownConverter', () => {
'<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'
) )
}) })
it('should preserve labels in sanitized HTML', () => {
const html =
'<ul data-type="taskList"><li data-type="taskItem"><label><input type="checkbox" checked disabled> Task with label</label></li></ul>'
const result = sanitizeHtml(html)
expect(result).toContain('<label>')
expect(result).toContain('<input type="checkbox" checked')
expect(result).toContain('Task with label')
})
}) })
describe('Task List Round Trip', () => { describe('Task List Round Trip', () => {
@ -423,8 +387,8 @@ describe('markdownConverter', () => {
it('should handle images with spaces in file:// protocol paths', () => { it('should handle images with spaces in file:// protocol paths', () => {
const markdown = '![My Image](file:///path/to/my image with spaces.png)' const markdown = '![My Image](file:///path/to/my image with spaces.png)'
const result = markdownToSafeHtml(markdown) const result = htmlToMarkdown(markdownToHtml(markdown))
expect(result).toContain('<img src="file:///path/to/my image with spaces.png" alt="My Image">') expect(result).toBe(markdown)
}) })
it('shoud img label to markdown', () => { it('shoud img label to markdown', () => {
@ -439,4 +403,65 @@ describe('markdownConverter', () => {
const result = markdownToHtml(markdown) const result = 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')
}) })
describe('Custom XML Tags Preservation', () => {
it('should preserve custom XML tags through markdown-to-HTML and HTML-to-markdown conversion', () => {
const markdown = 'Some text with <custom-tag>content</custom-tag> and more text'
const html = markdownToHtml(markdown)
const backToMarkdown = htmlToMarkdown(html)
expect(html).toContain('Some text with <custom-tag>content</custom-tag> and more text')
expect(backToMarkdown).toBe('Some text with <custom-tag>content</custom-tag> and more text')
})
it('should preserve single custom XML tags', () => {
const markdown = '<abc>'
const html = markdownToHtml(markdown)
const backToMarkdown = htmlToMarkdown(html)
expect(html).toBe('<p><abc></p>')
expect(backToMarkdown).toBe('<abc>')
})
it('should preserve single custom XML tags in html', () => {
const html = '<p><abc></p>'
const markdown = htmlToMarkdown(html)
const backToHtml = markdownToHtml(markdown)
expect(markdown).toBe('<abc>')
expect(backToHtml).toBe('<p><abc></p>')
})
it('should preserve custom XML tags mixed with regular markdown', () => {
const markdown = '# Heading\n\n<custom-widget id="widget1">Widget content</custom-widget>\n\n**Bold text**'
const html = markdownToHtml(markdown)
const backToMarkdown = htmlToMarkdown(html)
expect(html).toContain('<h1>Heading</h1>')
expect(html).toContain('<custom-widget id="widget1">Widget content</custom-widget>')
expect(html).toContain('<strong>Bold text</strong>')
expect(backToMarkdown).toContain('# Heading')
expect(backToMarkdown).toContain('<custom-widget id="widget1">Widget content</custom-widget>')
expect(backToMarkdown).toContain('**Bold text**')
})
})
describe('Typing behavior issues', () => {
it('should not add unwanted line breaks during simple text typing', () => {
const html = '<p>Hello world</p>'
const markdown = htmlToMarkdown(html)
const backToHtml = markdownToHtml(markdown)
expect(markdown).toBe('Hello world')
expect(backToHtml).toBe('<p>Hello world</p>\n')
})
it('should preserve simple paragraph structure during round-trip conversion', () => {
const originalHtml = '<p>This is a simple paragraph being typed</p>'
const markdown = htmlToMarkdown(originalHtml)
const backToHtml = markdownToHtml(markdown)
expect(markdown).toBe('This is a simple paragraph being typed')
expect(backToHtml).toBe('<p>This is a simple paragraph being typed</p>\n')
})
})
}) })

View File

@ -1,13 +1,77 @@
import { loggerService } from '@logger' import { loggerService } from '@logger'
import { TurndownPlugin } from '@truto/turndown-plugin-gfm' import { TurndownPlugin } from '@truto/turndown-plugin-gfm'
import DOMPurify from 'dompurify'
import he from 'he' import he from 'he'
import htmlTags, { type HtmlTags } from 'html-tags'
import * as htmlparser2 from 'htmlparser2'
import MarkdownIt from 'markdown-it' import MarkdownIt from 'markdown-it'
import striptags from 'striptags' import striptags from 'striptags'
import TurndownService from 'turndown' import TurndownService from 'turndown'
const logger = loggerService.withContext('markdownConverter') const logger = loggerService.withContext('markdownConverter')
function escapeCustomTags(html: string) {
let result = ''
let currentPos = 0
const processedPositions = new Set<number>()
const parser = new htmlparser2.Parser({
onopentagname(tagname) {
const startPos = parser.startIndex
const endPos = parser.endIndex
// Add content before this tag
result += html.slice(currentPos, startPos)
if (!htmlTags.includes(tagname as HtmlTags)) {
// This is a custom tag, escape it
const tagHtml = html.slice(startPos, endPos + 1)
result += tagHtml.replace(/</g, '&lt;').replace(/>/g, '&gt;')
} else {
// This is a standard HTML tag, keep it as-is
result += html.slice(startPos, endPos + 1)
}
currentPos = endPos + 1
},
onclosetag(tagname) {
const startPos = parser.startIndex
const endPos = parser.endIndex
// Skip if we've already processed this position (handles malformed HTML)
if (processedPositions.has(endPos) || endPos + 1 <= currentPos) {
return
}
processedPositions.add(endPos)
// Get the actual HTML content at this position to verify what tag it really is
const actualTagHtml = html.slice(startPos, endPos + 1)
const actualTagMatch = actualTagHtml.match(/<\/([^>]+)>/)
const actualTagName = actualTagMatch ? actualTagMatch[1] : tagname
if (!htmlTags.includes(actualTagName as HtmlTags)) {
// This is a custom tag, escape it
result += html.slice(currentPos, startPos)
result += actualTagHtml.replace(/</g, '&lt;').replace(/>/g, '&gt;')
currentPos = endPos + 1
} else {
// This is a standard HTML tag, add content up to and including the closing tag
result += html.slice(currentPos, endPos + 1)
currentPos = endPos + 1
}
},
onend() {
result += html.slice(currentPos)
}
})
parser.write(html)
parser.end()
return result
}
export interface TaskListOptions { export interface TaskListOptions {
label?: boolean label?: boolean
} }
@ -537,7 +601,10 @@ export const htmlToMarkdown = (html: string | null | undefined): string => {
} }
try { try {
return turndownService.turndown(html).trim() const encodedHtml = escapeCustomTags(html)
const turndownResult = turndownService.turndown(encodedHtml).trim()
const finalResult = he.decode(turndownResult)
return finalResult
} catch (error) { } catch (error) {
logger.error('Error converting HTML to Markdown:', error as Error) logger.error('Error converting HTML to Markdown:', error as Error)
return '' return ''
@ -572,94 +639,24 @@ export const markdownToHtml = (markdown: string | null | undefined): string => {
} }
) )
return md.render(processedMarkdown) let html = md.render(processedMarkdown)
const trimmedMarkdown = processedMarkdown.trim()
if (html.trim() === trimmedMarkdown) {
const singleTagMatch = trimmedMarkdown.match(/^<([a-zA-Z][^>\s]*)\/?>$/)
if (singleTagMatch) {
const tagName = singleTagMatch[1]
if (!htmlTags.includes(tagName.toLowerCase() as any)) {
html = `<p>${html}</p>`
}
}
}
return html
} catch (error) { } catch (error) {
logger.error('Error converting Markdown to HTML:', error as Error) logger.error('Error converting Markdown to HTML:', error as Error)
return '' return ''
} }
} }
/**
* Sanitizes HTML content using DOMPurify
* @param html - HTML string to sanitize
* @returns Sanitized HTML string
*/
export const sanitizeHtml = (html: string): string => {
if (!html || typeof html !== 'string') {
return ''
}
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: [
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'div',
'span',
'p',
'br',
'hr',
'strong',
'b',
'em',
'i',
'u',
's',
'del',
'ul',
'ol',
'li',
'blockquote',
'code',
'pre',
'a',
'img',
'table',
'thead',
'tbody',
'tfoot',
'tr',
'td',
'th',
'input',
'label',
'details',
'summary'
],
ALLOWED_ATTR: [
'href',
'title',
'alt',
'src',
'class',
'id',
'colspan',
'rowspan',
'type',
'checked',
'disabled',
'width',
'height',
'loading'
],
ALLOW_DATA_ATTR: true,
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|file|mailto|tel|callto|cid|xmpp):|[^a-z]|[a-z+.\\-]+(?:[^a-z+.\-:]|$))/i
})
}
/**
* Converts Markdown to safe HTML (combines conversion and sanitization)
* @param markdown - Markdown string to convert
* @returns Safe HTML string
*/
export const markdownToSafeHtml = (markdown: string): string => {
const html = markdownToHtml(markdown)
return sanitizeHtml(html)
}
/** /**
* Gets plain text preview from Markdown content * Gets plain text preview from Markdown content
* @param markdown - Markdown string * @param markdown - Markdown string

View File

@ -9509,7 +9509,9 @@ __metadata:
google-auth-library: "npm:^9.15.1" google-auth-library: "npm:^9.15.1"
graceful-fs: "npm:^4.2.11" graceful-fs: "npm:^4.2.11"
he: "npm:^1.2.0" he: "npm:^1.2.0"
html-tags: "npm:^5.1.0"
html-to-image: "npm:^1.11.13" html-to-image: "npm:^1.11.13"
htmlparser2: "npm:^10.0.0"
husky: "npm:^9.1.7" husky: "npm:^9.1.7"
i18next: "npm:^23.11.5" i18next: "npm:^23.11.5"
iconv-lite: "npm:^0.6.3" iconv-lite: "npm:^0.6.3"
@ -12442,7 +12444,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"domutils@npm:^3.0.1": "domutils@npm:^3.0.1, domutils@npm:^3.2.1":
version: 3.2.2 version: 3.2.2
resolution: "domutils@npm:3.2.2" resolution: "domutils@npm:3.2.2"
dependencies: dependencies:
@ -14880,6 +14882,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"html-tags@npm:^5.1.0":
version: 5.1.0
resolution: "html-tags@npm:5.1.0"
checksum: 10c0/2dda19bc07e75837d0c52984558d92e8b82768050e4d6421b3164b1cb6ca5e73719209c2b23c0fa71faf097a7a3d18cf7f2021b488f1b1f270fca516c4c634c9
languageName: node
linkType: hard
"html-to-image@npm:^1.11.13": "html-to-image@npm:^1.11.13":
version: 1.11.13 version: 1.11.13
resolution: "html-to-image@npm:1.11.13" resolution: "html-to-image@npm:1.11.13"
@ -14914,6 +14923,18 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"htmlparser2@npm:^10.0.0":
version: 10.0.0
resolution: "htmlparser2@npm:10.0.0"
dependencies:
domelementtype: "npm:^2.3.0"
domhandler: "npm:^5.0.3"
domutils: "npm:^3.2.1"
entities: "npm:^6.0.0"
checksum: 10c0/47cfa37e529c86a7ba9a1e0e6f951ad26ef8ca5af898ab6e8916fa02c0264c1453b4a65f28b7b8a7f9d0d29b5a70abead8203bf8b3f07bc69407e85e7d9a68e4
languageName: node
linkType: hard
"htmlparser2@npm:^8.0.2": "htmlparser2@npm:^8.0.2":
version: 8.0.2 version: 8.0.2
resolution: "htmlparser2@npm:8.0.2" resolution: "htmlparser2@npm:8.0.2"