mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-24 10:40:07 +08:00
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:
parent
197bae6065
commit
f085f6c7bc
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
|
||||
@ -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 ''
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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]
|
||||
)
|
||||
|
||||
// 删除节点
|
||||
|
||||
@ -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);
|
||||
}
|
||||
`
|
||||
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 = ''
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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, '<').replace(/>/g, '>')
|
||||
} 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, '<').replace(/>/g, '>')
|
||||
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
|
||||
|
||||
23
yarn.lock
23
yarn.lock
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user