feat: add font size and table of contents settings to RichEditor (#10034)

* feat: add font size and table of contents settings to RichEditor

- Introduced font size customization in the RichEditor component, allowing users to adjust the font size for better readability.
- Added a toggle for displaying a table of contents in the editor settings.
- Updated localization files to include new settings descriptions.
- Enhanced the NotesSettings component with a slider for font size adjustment and a switch for the table of contents feature.
- Migrated state management to include new settings in the Redux store.

* feat: enhance CodeEditor with customizable font size and responsive layout

* feat: enhance markdown conversion to preserve square brackets

- Improved the htmlToMarkdown function to correctly handle and preserve wiki-style double brackets [[foo]] and single brackets [foo] while maintaining proper Markdown link syntax.
- Added unit tests to verify the preservation of these bracket formats during conversion.

* feat: enhance YamlFrontMatterNodeView with editor content check

* fix

* chore

* chore: bump store persistence version to 153

---------

Co-authored-by: icarus <eurfelux@gmail.com>
This commit is contained in:
SuYao 2025-09-09 11:55:41 +08:00 committed by GitHub
parent 86f9e93e97
commit 56c1851848
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 259 additions and 110 deletions

View File

@ -5,6 +5,7 @@
min-height: 120px;
overflow-wrap: break-word;
word-break: break-word;
font-size: var(--editor-font-size, 16px);
}
.tiptap:focus {

View File

@ -15,7 +15,7 @@ interface ParsedProperty {
type: 'string' | 'array' | 'date' | 'number' | 'boolean'
}
const YamlFrontMatterNodeView: React.FC<NodeViewProps> = ({ node, updateAttributes }) => {
const YamlFrontMatterNodeView: React.FC<NodeViewProps> = ({ node, updateAttributes, editor }) => {
const { t } = useTranslation()
const [editingProperty, setEditingProperty] = useState<string | null>(null)
const [newPropertyName, setNewPropertyName] = useState('')
@ -408,6 +408,11 @@ const YamlFrontMatterNodeView: React.FC<NodeViewProps> = ({ node, updateAttribut
)
}
// Check if there's content in the entire editor (excluding YAML front matter)
const hasContent = useMemo(() => {
return editor.getText().trim().length > 0
}, [editor])
return (
<NodeViewWrapper
className="yaml-front-matter-wrapper"
@ -418,6 +423,7 @@ const YamlFrontMatterNodeView: React.FC<NodeViewProps> = ({ node, updateAttribut
}
}}>
<PropertiesContainer
hasContent={hasContent}
onClick={(e) => {
// Prevent node selection when clicking inside properties
e.stopPropagation()
@ -485,7 +491,7 @@ const YamlFrontMatterNodeView: React.FC<NodeViewProps> = ({ node, updateAttribut
/>
</PropertyRow>
) : (
<AddPropertyRow onClick={() => setShowAddProperty(true)}>
<AddPropertyRow hasContent={hasContent} onClick={() => setShowAddProperty(true)}>
<PropertyIcon>
<Plus size={16} />
</PropertyIcon>
@ -497,7 +503,7 @@ const YamlFrontMatterNodeView: React.FC<NodeViewProps> = ({ node, updateAttribut
)
}
const PropertiesContainer = styled.div`
const PropertiesContainer = styled.div<{ hasContent?: boolean }>`
margin: 16px 0;
padding: 0;
display: flex;
@ -705,7 +711,7 @@ const ArrayInput = styled(Input)`
}
`
const AddPropertyRow = styled.button`
const AddPropertyRow = styled.button<{ hasContent?: boolean }>`
display: flex;
align-items: center;
padding: 6px 8px;
@ -716,16 +722,22 @@ const AddPropertyRow = styled.button`
cursor: pointer;
border-radius: 6px;
width: 100%;
opacity: ${({ hasContent }) => (hasContent ? 0 : 1)};
transition: opacity 0.2s;
&:hover {
background-color: var(--color-hover);
}
${PropertiesContainer}:hover & {
opacity: 1;
}
`
const AddPropertyText = styled.div`
font-size: 14px;
font-family: var(--font-family);
color: var(--color-text);
color: var(--color-text-secondary);
`
const PropertyActions = styled.div`

View File

@ -47,7 +47,8 @@ const RichEditor = ({
showTableOfContents = false,
enableContentSearch = false,
isFullWidth = false,
fontFamily = 'default'
fontFamily = 'default',
fontSize = 16
// toolbarItems: _toolbarItems // TODO: Implement custom toolbar items
}: RichEditorProps & { ref?: React.RefObject<RichEditorRef | null> }) => {
// Use the rich editor hook for complete editor management
@ -388,6 +389,7 @@ const RichEditor = ({
$maxHeight={maxHeight}
$isFullWidth={isFullWidth}
$fontFamily={fontFamily}
$fontSize={fontSize}
onKeyDown={onKeyDownEditor}>
{showToolbar && (
<Toolbar

View File

@ -5,6 +5,7 @@ export const RichEditorWrapper = styled.div<{
$maxHeight?: number
$isFullWidth?: boolean
$fontFamily?: 'default' | 'serif'
$fontSize?: number
}>`
display: flex;
flex-direction: column;
@ -16,6 +17,7 @@ export const RichEditorWrapper = styled.div<{
width: ${({ $isFullWidth }) => ($isFullWidth ? '100%' : '60%')};
margin: ${({ $isFullWidth }) => ($isFullWidth ? '0' : '0 auto')};
font-family: ${({ $fontFamily }) => ($fontFamily === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)')};
${({ $fontSize }) => $fontSize && `--editor-font-size: ${$fontSize}px;`}
${({ $minHeight }) => $minHeight && `min-height: ${$minHeight}px;`}
${({ $maxHeight }) => $maxHeight && `max-height: ${$maxHeight}px;`}

View File

@ -48,6 +48,8 @@ export interface RichEditorProps {
isFullWidth?: boolean
/** Font family setting */
fontFamily?: 'default' | 'serif'
/** Font size in pixels */
fontSize?: number
}
export interface ToolbarItem {

View File

@ -1743,8 +1743,15 @@
"compress_content": "Content Compression",
"compress_content_description": "When enabled, it will limit the number of characters per line, reducing the content displayed on the screen, but making longer paragraphs more readable.",
"default_font": "Default font",
"font_size": "Font Size",
"font_size_description": "Adjust the font size for better reading experience (10-30px)",
"font_size_large": "Large",
"font_size_medium": "Medium",
"font_size_small": "Small",
"font_title": "Font settings",
"serif_font": "Serif font",
"show_table_of_contents": "Show Table of Contents",
"show_table_of_contents_description": "Display a table of contents sidebar for easy navigation within documents",
"title": "Display Settings"
},
"editor": {

View File

@ -1743,8 +1743,15 @@
"compress_content": "缩减栏宽",
"compress_content_description": "开启后将限制每行字数,使屏幕显示的内容减少",
"default_font": "默认字体",
"font_size": "字体大小",
"font_size_description": "调整字体大小以获得更好的阅读体验 (10-30px)",
"font_size_large": "大",
"font_size_medium": "中",
"font_size_small": "小",
"font_title": "字体设置",
"serif_font": "衬线字体",
"show_table_of_contents": "显示目录大纲",
"show_table_of_contents_description": "显示目录大纲侧边栏,方便文档内导航",
"title": "显示设置"
},
"editor": {

View File

@ -1743,8 +1743,15 @@
"compress_content": "縮減欄寬",
"compress_content_description": "開啟後將限制每行字數,使屏幕顯示的內容減少",
"default_font": "默認字體",
"font_size": "字體大小",
"font_size_description": "調整字體大小以獲得更好的閱讀體驗 (10-30px)",
"font_size_large": "大",
"font_size_medium": "中",
"font_size_small": "小",
"font_title": "字體設置",
"serif_font": "襯線字體",
"show_table_of_contents": "顯示目錄大綱",
"show_table_of_contents_description": "顯示目錄大綱側邊欄,方便文檔內導航",
"title": "顯示"
},
"editor": {

View File

@ -7,7 +7,7 @@ import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace'
import { findNodeInTree } from '@renderer/services/NotesTreeService'
import { Breadcrumb, BreadcrumbProps, Dropdown, Tooltip } from 'antd'
import { t } from 'i18next'
import { MoreHorizontal, PanelLeftClose, PanelRightClose } from 'lucide-react'
import { MoreHorizontal, PanelLeftClose, PanelRightClose, Star } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import styled from 'styled-components'
@ -15,16 +15,23 @@ import { menuItems } from './MenuConfig'
const logger = loggerService.withContext('HeaderNavbar')
const HeaderNavbar = ({ notesTree, getCurrentNoteContent }) => {
const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar }) => {
const { showWorkspace, toggleShowWorkspace } = useShowWorkspace()
const { activeNode } = useActiveNode(notesTree)
const [breadcrumbItems, setBreadcrumbItems] = useState<Required<BreadcrumbProps>['items']>([])
const { settings, updateSettings } = useNotesSettings()
const canShowStarButton = activeNode?.type === 'file' && onToggleStar
const handleToggleShowWorkspace = useCallback(() => {
toggleShowWorkspace()
}, [toggleShowWorkspace])
const handleToggleStarred = useCallback(() => {
if (activeNode) {
onToggleStar(activeNode.id)
}
}, [activeNode, onToggleStar])
const handleCopyContent = useCallback(async () => {
try {
const content = getCurrentNoteContent?.()
@ -132,6 +139,17 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent }) => {
<Breadcrumb items={breadcrumbItems} />
</NavbarCenter>
<NavbarRight style={{ paddingRight: 0 }}>
{canShowStarButton && (
<Tooltip title={activeNode.isStarred ? t('notes.unstar') : t('notes.star')} mouseEnterDelay={0.8}>
<StarButton onClick={handleToggleStarred}>
{activeNode.isStarred ? (
<Star size={18} fill="var(--color-status-warning)" stroke="var(--color-status-warning)" />
) : (
<Star size={18} />
)}
</StarButton>
</Tooltip>
)}
<Tooltip title={t('notes.settings.title')} mouseEnterDelay={0.8}>
<Dropdown
menu={{ items: menuItems.map(buildMenuItem) }}
@ -187,4 +205,24 @@ export const NavbarIcon = styled.div`
}
`
export const StarButton = styled.div`
-webkit-app-region: none;
border-radius: 8px;
height: 30px;
padding: 0 7px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
transition: all 0.2s ease-in-out;
cursor: pointer;
svg {
color: inherit;
}
&:hover {
background-color: var(--color-background-mute);
}
`
export default HeaderNavbar

View File

@ -33,6 +33,13 @@ export const menuItems: MenuItem[] = [
action: (settings, updateSettings) => updateSettings({ isFullWidth: !settings.isFullWidth }),
isActive: (settings) => !settings.isFullWidth
},
{
key: 'table-of-contents',
labelKey: 'notes.settings.display.show_table_of_contents',
icon: Type,
action: (settings, updateSettings) => updateSettings({ showTableOfContents: !settings.showTableOfContents }),
isActive: (settings) => settings.showTableOfContents
},
{
key: 'divider1',
type: 'divider',
@ -54,6 +61,29 @@ export const menuItems: MenuItem[] = [
labelKey: 'notes.settings.display.serif_font',
action: (_, updateSettings) => updateSettings({ fontFamily: 'serif' }),
isActive: (settings) => settings.fontFamily === 'serif'
},
{
key: 'divider2',
type: 'divider',
labelKey: ''
},
{
key: 'font-size-small',
labelKey: 'notes.settings.display.font_size_small',
action: (_, updateSettings) => updateSettings({ fontSize: 14 }),
isActive: (settings) => settings.fontSize === 14
},
{
key: 'font-size-medium',
labelKey: 'notes.settings.display.font_size_medium',
action: (_, updateSettings) => updateSettings({ fontSize: 16 }),
isActive: (settings) => settings.fontSize === 16
},
{
key: 'font-size-large',
labelKey: 'notes.settings.display.font_size_large',
action: (_, updateSettings) => updateSettings({ fontSize: 20 }),
isActive: (settings) => settings.fontSize === 20
}
]
}

View File

@ -59,16 +59,19 @@ const NotesEditor: FC<NotesEditorProps> = memo(
<>
<RichEditorContainer>
{tmpViewMode === 'source' ? (
<CodeEditor
value={currentContent}
language="markdown"
onChange={onMarkdownChange}
height="100%"
expanded={false}
style={{
height: '100%'
}}
/>
<SourceEditorWrapper isFullWidth={settings.isFullWidth} fontSize={settings.fontSize}>
<CodeEditor
value={currentContent}
language="markdown"
onChange={onMarkdownChange}
height="100%"
expanded={false}
style={{
height: '100%',
fontSize: `${settings.fontSize}px`
}}
/>
</SourceEditorWrapper>
) : (
<RichEditor
key={`${activeNodeId}-${tmpViewMode === 'preview' ? 'preview' : 'read'}`}
@ -78,11 +81,12 @@ const NotesEditor: FC<NotesEditorProps> = memo(
onCommandsReady={handleCommandsReady}
showToolbar={tmpViewMode === 'preview'}
editable={tmpViewMode === 'preview'}
showTableOfContents
showTableOfContents={settings.showTableOfContents}
enableContentSearch
className="notes-rich-editor"
isFullWidth={settings.isFullWidth}
fontFamily={settings.fontFamily}
fontSize={settings.fontSize}
/>
)}
</RichEditorContainer>
@ -172,6 +176,28 @@ const RichEditorContainer = styled.div`
}
`
const SourceEditorWrapper = styled.div<{ isFullWidth: boolean; fontSize: number }>`
height: 100%;
width: ${({ isFullWidth }) => (isFullWidth ? '100%' : '60%')};
margin: ${({ isFullWidth }) => (isFullWidth ? '0' : '0 auto')};
/* 应用字体大小到CodeEditor */
.monaco-editor {
font-size: ${({ fontSize }) => fontSize}px !important;
}
/* 确保CodeEditor内部元素也应用字体大小 */
.monaco-editor .monaco-editor-background,
.monaco-editor .inputarea.ime-input,
.monaco-editor .monaco-editor-container,
.monaco-editor .overflow-guard,
.monaco-editor .monaco-scrollable-element,
.monaco-editor .lines-content.monaco-editor-background,
.monaco-editor .view-line {
font-size: ${({ fontSize }) => fontSize}px !important;
}
`
const BottomPanel = styled.div`
padding: 8px 16px;
border-top: 0.5px solid var(--color-border);

View File

@ -1,83 +0,0 @@
import { Navbar, NavbarLeft, NavbarRight } from '@renderer/components/app/Navbar'
import { HStack } from '@renderer/components/Layout'
import { isMac } from '@renderer/config/constant'
import { useFullscreen } from '@renderer/hooks/useFullscreen'
import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace'
import { Tooltip } from 'antd'
import { PanelLeftClose, PanelRightClose } from 'lucide-react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
const NotesNavbar = () => {
const { t } = useTranslation()
const { showWorkspace, toggleShowWorkspace } = useShowWorkspace()
const isFullscreen = useFullscreen()
const handleToggleShowWorkspace = useCallback(() => {
toggleShowWorkspace()
}, [toggleShowWorkspace])
return (
<Navbar className="notes-navbar">
{showWorkspace && (
<NavbarLeft style={{ justifyContent: 'space-between', borderRight: 'none', padding: 0 }}>
<Tooltip title={t('navbar.hide_sidebar')} mouseEnterDelay={0.8}>
<NavbarIcon onClick={handleToggleShowWorkspace} style={{ marginLeft: isMac && !isFullscreen ? 16 : 0 }}>
<PanelLeftClose size={18} />
</NavbarIcon>
</Tooltip>
</NavbarLeft>
)}
<NavbarRight style={{ justifyContent: 'space-between', flex: 1 }} className="notes-navbar-right">
<HStack alignItems="center">
{!showWorkspace && (
<Tooltip title={t('navbar.show_sidebar')} mouseEnterDelay={0.8}>
<NavbarIcon
onClick={handleToggleShowWorkspace}
style={{ marginRight: 8, marginLeft: isMac && !isFullscreen ? 4 : -12 }}>
<PanelRightClose size={18} />
</NavbarIcon>
</Tooltip>
)}
</HStack>
</NavbarRight>
</Navbar>
)
}
export const NavbarIcon = styled.div`
-webkit-app-region: none;
border-radius: 8px;
height: 30px;
padding: 0 7px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
transition: all 0.2s ease-in-out;
cursor: pointer;
.iconfont {
font-size: 18px;
color: var(--color-icon);
&.icon-a-addchat {
font-size: 20px;
}
&.icon-a-darkmode {
font-size: 20px;
}
&.icon-appstore {
font-size: 20px;
}
}
.anticon {
color: var(--color-icon);
font-size: 16px;
}
&:hover {
background-color: var(--color-background-mute);
color: var(--color-icon-white);
}
`
export default NotesNavbar

View File

@ -20,8 +20,8 @@ import { selectActiveFilePath, selectSortType, setActiveFilePath, setSortType }
import { NotesSortType, NotesTreeNode } from '@renderer/types/note'
import { FileChangeEvent } from '@shared/config/types'
import { useLiveQuery } from 'dexie-react-hooks'
import { AnimatePresence, motion } from 'framer-motion'
import { debounce } from 'lodash'
import { AnimatePresence, motion } from 'motion/react'
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -629,7 +629,11 @@ const NotesPage: FC = () => {
)}
</AnimatePresence>
<EditorWrapper>
<HeaderNavbar notesTree={notesTree} getCurrentNoteContent={getCurrentNoteContent} />
<HeaderNavbar
notesTree={notesTree}
getCurrentNoteContent={getCurrentNoteContent}
onToggleStar={handleToggleStar}
/>
<NotesEditor
activeNodeId={activeNode?.id}
currentContent={currentContent}

View File

@ -4,7 +4,7 @@ import { useTheme } from '@renderer/context/ThemeProvider'
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
import { initWorkSpace } from '@renderer/services/NotesService'
import { EditorView } from '@renderer/types'
import { Button, Input, message, Switch } from 'antd'
import { Button, Input, message, Slider, Switch } from 'antd'
import { FolderOpen } from 'lucide-react'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -167,9 +167,33 @@ const NotesSettings: FC = () => {
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('notes.settings.display.compress_content')}</SettingRowTitle>
<Switch checked={!settings.isFullWidth} onChange={(checked) => updateSettings({ isFullWidth: checked })} />
<Switch checked={!settings.isFullWidth} onChange={(checked) => updateSettings({ isFullWidth: !checked })} />
</SettingRow>
<SettingHelpText>{t('notes.settings.display.compress_content_description')}</SettingHelpText>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('notes.settings.display.font_size')}</SettingRowTitle>
<FontSizeContainer>
<Slider
min={10}
max={30}
value={settings.fontSize}
onChange={(value) => updateSettings({ fontSize: value })}
style={{ width: 200, marginRight: 16 }}
/>
<FontSizeValue>{settings.fontSize}px</FontSizeValue>
</FontSizeContainer>
</SettingRow>
<SettingHelpText>{t('notes.settings.display.font_size_description')}</SettingHelpText>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('notes.settings.display.show_table_of_contents')}</SettingRowTitle>
<Switch
checked={settings.showTableOfContents}
onChange={(checked) => updateSettings({ showTableOfContents: checked })}
/>
</SettingRow>
<SettingHelpText>{t('notes.settings.display.show_table_of_contents_description')}</SettingHelpText>
</SettingGroup>
</SettingContainer>
)
@ -194,4 +218,15 @@ const ActionButtons = styled.div`
align-self: flex-start;
`
const FontSizeContainer = styled.div`
display: flex;
align-items: center;
`
const FontSizeValue = styled.span`
min-width: 40px;
font-size: 14px;
color: var(--color-text-secondary);
`
export default NotesSettings

View File

@ -1,6 +1,6 @@
import { ThemeMode } from '@renderer/types'
import { AnimatePresence, motion } from 'framer-motion'
import { ChevronDown, ChevronRight } from 'lucide-react'
import { AnimatePresence, motion } from 'motion/react'
import { useState } from 'react'
import styled from 'styled-components'

View File

@ -67,7 +67,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 152,
version: 153,
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'],
migrate
},

View File

@ -2439,6 +2439,18 @@ const migrateConfig = {
logger.error('migrate 152 error', error as Error)
return state
}
},
'153': (state: RootState) => {
try {
if (state.note.settings) {
state.note.settings.fontSize = notesInitialState.settings.fontSize
state.note.settings.showTableOfContents = notesInitialState.settings.showTableOfContents
}
return state
} catch (error) {
logger.error('migrate 153 error', error as Error)
return state
}
}
}

View File

@ -6,6 +6,8 @@ import { NotesSortType } from '@renderer/types/note'
export interface NotesSettings {
isFullWidth: boolean
fontFamily: 'default' | 'serif'
fontSize: number
showTableOfContents: boolean
defaultViewMode: 'edit' | 'read'
defaultEditMode: Omit<EditorView, 'read'>
showTabStatus: boolean
@ -26,6 +28,8 @@ export const initialState: NoteState = {
settings: {
isFullWidth: true,
fontFamily: 'default',
fontSize: 16,
showTableOfContents: true,
defaultViewMode: 'edit',
defaultEditMode: 'preview',
showTabStatus: true,

View File

@ -485,7 +485,7 @@ describe('markdownConverter', () => {
})
})
describe('shoud keep YAML front matter', () => {
describe('should keep YAML front matter', () => {
it('should keep YAML front matter', () => {
const markdown = `---
tags:
@ -505,4 +505,19 @@ cssclasses:
expect(backToMarkdown).toBe(markdown)
})
})
describe('should keep []', () => {
it('should keep [[foo]]', () => {
const markdown = `[[foo]]`
const result = markdownToHtml(markdown)
const backToMarkdown = htmlToMarkdown(result)
expect(backToMarkdown).toBe(markdown)
})
it('should keep []', () => {
const markdown = `[foo]`
const result = markdownToHtml(markdown)
const backToMarkdown = htmlToMarkdown(result)
expect(backToMarkdown).toBe(markdown)
})
})
})

View File

@ -750,7 +750,35 @@ export const htmlToMarkdown = (html: string | null | undefined): string => {
try {
const encodedHtml = escapeCustomTags(html)
const turndownResult = turndownService.turndown(encodedHtml)
const finalResult = he.decode(turndownResult)
let finalResult = he.decode(turndownResult)
// Post-process to unescape square brackets that are not part of Markdown link syntax
// This preserves wiki-style double brackets [[foo]] and single brackets [foo]
// but keeps proper Markdown links [text](url) intact
// Use a more sophisticated approach: check for the link pattern first,
// then unescape standalone brackets
// First, protect actual Markdown links by temporarily replacing them
const linkPlaceholders: string[] = []
let linkCounter = 0
// Find and replace all Markdown links with placeholders
finalResult = finalResult.replace(/\\\[([^\]]*)\\\]\([^)]*\)/g, (match) => {
const placeholder = `__MDLINK_${linkCounter++}__`
linkPlaceholders[linkCounter - 1] = match
return placeholder
})
// Now unescape all remaining square brackets
finalResult = finalResult.replace(/\\\[/g, '[').replace(/\\\]/g, ']')
// Restore the Markdown links
for (let i = 0; i < linkPlaceholders.length; i++) {
const placeholder = `__MDLINK_${i}__`
finalResult = finalResult.replace(placeholder, linkPlaceholders[i])
}
return finalResult
} catch (error) {
logger.error('Error converting HTML to Markdown:', error as Error)