mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-06 21:35:52 +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",
|
"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",
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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 ''
|
||||||
|
|||||||
@ -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}>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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]
|
||||||
)
|
)
|
||||||
|
|
||||||
// 删除节点
|
// 删除节点
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|||||||
@ -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`
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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 = ''
|
const markdown = ''
|
||||||
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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, '<').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 {
|
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
|
||||||
|
|||||||
23
yarn.lock
23
yarn.lock
@ -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"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user