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",
"google-auth-library": "^9.15.1",
"he": "^1.2.0",
"html-tags": "^5.1.0",
"html-to-image": "^1.11.13",
"htmlparser2": "^10.0.0",
"husky": "^9.1.7",
"i18next": "^23.11.5",
"iconv-lite": "^0.6.3",

View File

@ -148,6 +148,12 @@
left: 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 */
@ -471,6 +477,14 @@
align-items: center;
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

View File

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

View File

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

View File

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

View File

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

View File

@ -109,7 +109,9 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent }) => {
}, [activeNode, notesTree])
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">
{showWorkspace && (
<Tooltip title={t('navbar.hide_sidebar')} mouseEnterDelay={0.8}>

View File

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

View File

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

View File

@ -522,7 +522,7 @@ const SidebarContainer = styled.div`
width: 250px;
height: 100vh;
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;
display: flex;
flex-direction: column;
@ -568,7 +568,7 @@ const TreeNodeContainer = styled.div<{
if (props.active) return 'var(--color-background-soft)'
return 'transparent'
}};
border: 1px solid
border: 0.5px solid
${(props) => {
if (props.isDragInside) return 'var(--color-primary)'
if (props.active) return 'var(--color-border)'
@ -669,7 +669,7 @@ const EditInput = styled(Input)`
.ant-input {
font-size: 13px;
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 }>`
padding: 8px 12px;
border-bottom: 1px solid var(--color-border);
border-bottom: 0.5px solid var(--color-border);
display: flex;
justify-content: ${(props) => (props.isStarView || props.isSearchView ? 'flex-start' : 'center')};
height: var(--navbar-height);
`
const HeaderActions = styled.div`

View File

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

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { htmlToMarkdown, markdownToHtml, markdownToSafeHtml, sanitizeHtml } from '../markdownConverter'
import { htmlToMarkdown, markdownToHtml } from '../markdownConverter'
describe('markdownConverter', () => {
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', () => {
it('should wrap task items with labels when label option is true', () => {
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'
)
})
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', () => {
@ -423,8 +387,8 @@ describe('markdownConverter', () => {
it('should handle images with spaces in file:// protocol paths', () => {
const markdown = '![My Image](file:///path/to/my image with spaces.png)'
const result = markdownToSafeHtml(markdown)
expect(result).toContain('<img src="file:///path/to/my image with spaces.png" alt="My Image">')
const result = htmlToMarkdown(markdownToHtml(markdown))
expect(result).toBe(markdown)
})
it('shoud img label to markdown', () => {
@ -439,4 +403,65 @@ describe('markdownConverter', () => {
const result = markdownToHtml(markdown)
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 { TurndownPlugin } from '@truto/turndown-plugin-gfm'
import DOMPurify from 'dompurify'
import he from 'he'
import htmlTags, { type HtmlTags } from 'html-tags'
import * as htmlparser2 from 'htmlparser2'
import MarkdownIt from 'markdown-it'
import striptags from 'striptags'
import TurndownService from 'turndown'
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 {
label?: boolean
}
@ -537,7 +601,10 @@ export const htmlToMarkdown = (html: string | null | undefined): string => {
}
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) {
logger.error('Error converting HTML to Markdown:', error as Error)
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) {
logger.error('Error converting Markdown to HTML:', error as Error)
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
* @param markdown - Markdown string

View File

@ -9509,7 +9509,9 @@ __metadata:
google-auth-library: "npm:^9.15.1"
graceful-fs: "npm:^4.2.11"
he: "npm:^1.2.0"
html-tags: "npm:^5.1.0"
html-to-image: "npm:^1.11.13"
htmlparser2: "npm:^10.0.0"
husky: "npm:^9.1.7"
i18next: "npm:^23.11.5"
iconv-lite: "npm:^0.6.3"
@ -12442,7 +12444,7 @@ __metadata:
languageName: node
linkType: hard
"domutils@npm:^3.0.1":
"domutils@npm:^3.0.1, domutils@npm:^3.2.1":
version: 3.2.2
resolution: "domutils@npm:3.2.2"
dependencies:
@ -14880,6 +14882,13 @@ __metadata:
languageName: node
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":
version: 1.11.13
resolution: "html-to-image@npm:1.11.13"
@ -14914,6 +14923,18 @@ __metadata:
languageName: node
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":
version: 8.0.2
resolution: "htmlparser2@npm:8.0.2"