From 114aa0c49b1331a6371ba544350bfdcfe1cdd4ca Mon Sep 17 00:00:00 2001 From: YihongSuo-148 Date: Thu, 22 Oct 2026 20:37:27 +0800 Subject: [PATCH] memo: add a persistent paragraph first-line indent preference --- app/flowix-desktop/src/config/user.rs | 78 +++++++++++++++++++ .../hooks/use-apply-font-settings.test.tsx | 41 ++++++++++ .../hooks/use-apply-font-settings.ts | 6 +- .../preferences/sections/format.test.tsx | 70 +++++++++++++++++ .../features/preferences/sections/format.tsx | 43 ++++++---- .../store/user-settings-store.test.ts | 65 +++++++++++++++- .../preferences/store/user-settings-store.ts | 1 + app/flowix-web/lib/constants.ts | 3 + app/flowix-web/lib/i18n/messages.en-US.ts | 4 + app/flowix-web/lib/i18n/messages.zh-CN.ts | 4 + app/flowix-web/styles/editor.css | 1 + app/flowix-web/styles/editor.test.ts | 46 +++++++++++ app/flowix-web/styles/index.css | 1 + docs/EDITOR-FORMAT.md | 15 ++++ 14 files changed, 359 insertions(+), 19 deletions(-) create mode 100644 app/flowix-web/features/preferences/hooks/use-apply-font-settings.test.tsx create mode 100644 app/flowix-web/features/preferences/sections/format.test.tsx create mode 100644 app/flowix-web/styles/editor.test.ts create mode 100644 docs/EDITOR-FORMAT.md diff --git a/app/flowix-desktop/src/config/user.rs b/app/flowix-desktop/src/config/user.rs index 9de257a81..691a07a1d 100644 --- a/app/flowix-desktop/src/config/user.rs +++ b/app/flowix-desktop/src/config/user.rs @@ -66,6 +66,9 @@ pub struct FormatConfig { pub font_size: f64, #[serde(default)] pub line_height: f64, + /// First-line indent in em; the editor offers 0 or 2. + #[serde(default, deserialize_with = "deserialize_first_line_indent")] + pub first_line_indent: u8, /// 文档编辑区最大�?�?(px) —应用�?Tiptap ProseMirror max-width�? /// 镜像前�? `FormatConfig.documentWidth`, �?preference.json 没�?字�? /// 时由 `#[serde(default)]` 兜底�?0, 前�? sanitizeSettings 会用默�?值�?盖�? @@ -73,6 +76,14 @@ pub struct FormatConfig { pub document_width: f64, } +fn deserialize_first_line_indent<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let value = serde_json::Value::deserialize(deserializer)?; + Ok(if value.as_f64() == Some(2.0) { 2 } else { 0 }) +} + #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PropertyFieldConfig { @@ -1655,6 +1666,73 @@ mod tests { ) } + #[test] + fn first_line_indent_defaults_to_zero_for_legacy_preferences() { + let preference: PreferenceFile = serde_json::from_value(serde_json::json!({ + "format": { "fontSize": 18, "lineHeight": 1.7 } + })) + .unwrap(); + + assert_eq!(preference.format.first_line_indent, 0); + assert_eq!(preference.format.font_size, 18.0); + assert_eq!(PreferenceFile::default().format.first_line_indent, 0); + } + + #[test] + fn first_line_indent_invalid_values_preserve_other_preferences() { + for value in [ + serde_json::json!(null), + serde_json::json!(-2), + serde_json::json!(1), + serde_json::json!(4), + serde_json::json!("2"), + serde_json::json!(true), + serde_json::json!({}), + ] { + let preference: PreferenceFile = serde_json::from_value(serde_json::json!({ + "format": { "firstLineIndent": value, "fontSize": 18 }, + "theme": "dark" + })) + .unwrap(); + assert_eq!(preference.format.first_line_indent, 0); + assert_eq!(preference.format.font_size, 18.0); + assert!(matches!(preference.theme, Theme::Dark)); + } + } + + #[test] + fn first_line_indent_survives_preference_updates_and_reload() { + let home = tempfile::tempdir().unwrap(); + let store = test_user_config_store(home.path().to_path_buf()); + store + .patch_preference(serde_json::json!({ + "format": { "firstLineIndent": 2, "fontSize": 18 } + })) + .unwrap(); + store + .patch_preference(serde_json::json!({ "language": "zh-CN" })) + .unwrap(); + store + .set_preferred_font("system-ui".into(), "system-ui".into()) + .unwrap(); + drop(store); + + let store = test_user_config_store(home.path().to_path_buf()); + let preference = store.get_preference(); + assert_eq!(preference.format.first_line_indent, 2); + assert_eq!(preference.format.font_size, 18.0); + assert_eq!(preference.format.font_id.as_deref(), Some("system-ui")); + assert_eq!(preference.language, "zh-CN"); + + store + .patch_preference(serde_json::json!({ "format": { "firstLineIndent": 0 } })) + .unwrap(); + drop(store); + + let store = test_user_config_store(home.path().to_path_buf()); + assert_eq!(store.get_preference().format.first_line_indent, 0); + } + #[test] fn storage_rebuild_replaces_invalid_preferences_and_toml() { let home = tempfile::tempdir().unwrap(); diff --git a/app/flowix-web/features/preferences/hooks/use-apply-font-settings.test.tsx b/app/flowix-web/features/preferences/hooks/use-apply-font-settings.test.tsx new file mode 100644 index 000000000..3a7aac9a4 --- /dev/null +++ b/app/flowix-web/features/preferences/hooks/use-apply-font-settings.test.tsx @@ -0,0 +1,41 @@ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { DEFAULT_USER_SETTINGS, type FormatConfig } from '@/lib/constants'; +import { useApplyFontSettings } from './use-apply-font-settings'; + +let container: HTMLDivElement; +let root: Root; +let originalStyle: string | null; + +function ApplyFormat({ format }: { format: FormatConfig | undefined }) { + useApplyFontSettings(format); + return null; +} + +beforeEach(() => { + originalStyle = document.documentElement.getAttribute('style'); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + if (originalStyle === null) document.documentElement.removeAttribute('style'); + else document.documentElement.setAttribute('style', originalStyle); +}); + +describe('document paragraph indentation', () => { + it('applies and removes the indentation when the preference changes', async () => { + await act(async () => root.render()); + await act(async () => root.render()); + + expect(document.documentElement.style.getPropertyValue('--editor-first-line-indent')).toBe('2em'); + + await act(async () => root.render()); + + expect(document.documentElement.style.getPropertyValue('--editor-first-line-indent')).toBe('0em'); + }); +}); diff --git a/app/flowix-web/features/preferences/hooks/use-apply-font-settings.ts b/app/flowix-web/features/preferences/hooks/use-apply-font-settings.ts index e25685098..514085054 100644 --- a/app/flowix-web/features/preferences/hooks/use-apply-font-settings.ts +++ b/app/flowix-web/features/preferences/hooks/use-apply-font-settings.ts @@ -10,7 +10,7 @@ import { } from '@features/preferences/font-cache'; /** - * 将用户在「Preferences → Format」中选择的字体/字号/行间距/文档宽度写入 :root + * 将用户在「Preferences → Format」中选择的字体/字号/行间距/首行缩进/文档宽度写入 :root * 的 CSS 变量。 * * 变量写入 :root 是为了 hook 点稳定, 但消费方 (css/editor.css 的 @@ -22,6 +22,7 @@ import { * --editor-font-family * --editor-font-size * --editor-line-height + * --editor-first-line-indent * --editor-document-width * * 调用方: app.tsx (主窗口 + 偏好设置窗口都会挂载, 因此跨窗口都会立即响应)。 @@ -51,11 +52,12 @@ export function useApplyFontSettings(format: FormatConfig | undefined) { if (typeof format.lineHeight === 'number' && !Number.isNaN(format.lineHeight)) { root.style.setProperty('--editor-line-height', String(format.lineHeight)); } + root.style.setProperty('--editor-first-line-indent', format.firstLineIndent === 2 ? '2em' : '0em'); if (typeof format.documentWidth === 'number' && !Number.isNaN(format.documentWidth)) { root.style.setProperty( '--editor-document-width', format.documentWidth > DOCUMENT_WIDTH_FULL_THRESHOLD ? 'none' : `${format.documentWidth}px`, ); } - }, [format?.fontFamily, format?.fontSize, format?.lineHeight, format?.documentWidth]); + }, [format?.fontFamily, format?.fontSize, format?.lineHeight, format?.firstLineIndent, format?.documentWidth]); } diff --git a/app/flowix-web/features/preferences/sections/format.test.tsx b/app/flowix-web/features/preferences/sections/format.test.tsx new file mode 100644 index 000000000..17d8a23d3 --- /dev/null +++ b/app/flowix-web/features/preferences/sections/format.test.tsx @@ -0,0 +1,70 @@ +import { act, useState } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { DEFAULT_USER_SETTINGS, type FormatConfig } from '@/lib/constants'; +import { I18nProvider } from '@/lib/i18n'; +import { FormatSection } from './format'; + +vi.mock('@platform/tauri/event-bus', () => ({ subscribe: () => () => {} })); +vi.mock('@features/preferences/font-cache', async (importOriginal) => ({ + ...await importOriginal(), + getDownloadedFontStatus: vi.fn(async () => ({})), +})); + +let container: HTMLDivElement; +let root: Root; +const updateSettings = vi.fn(); + +function FormatPreferences() { + const [format, setFormat] = useState(DEFAULT_USER_SETTINGS.format); + return ( + + }) => { + updateSettings(updates); + setFormat((current) => ({ ...current, ...updates.format })); + }} + /> + + ); +} + +beforeEach(() => { + updateSettings.mockClear(); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); +}); + +it('previews the chosen indent and clears it when resetting format preferences', async () => { + await act(async () => root.render()); + const trigger = container.querySelector('#preferences-format-first-line-indent'); + expect(trigger?.textContent).toBe('不缩进'); + + await act(async () => trigger!.click()); + const indentOption = [...document.querySelectorAll('[data-flowix-surface="select"] button')] + .find((button) => button.textContent === '2 字符'); + expect(indentOption).toBeDefined(); + await act(async () => indentOption!.click()); + + expect(updateSettings).toHaveBeenCalledWith({ format: { firstLineIndent: 2 } }); + expect(trigger?.textContent).toBe('2 字符'); + expect([...container.querySelectorAll('p[style]')].map((paragraph) => paragraph.style.textIndent)) + .toEqual(['2em', '2em']); + + const reset = [...container.querySelectorAll('button')] + .find((button) => button.textContent === '恢复默认'); + expect(reset).toBeDefined(); + await act(async () => reset!.click()); + + expect(updateSettings).toHaveBeenLastCalledWith({ format: DEFAULT_USER_SETTINGS.format }); + expect(trigger?.textContent).toBe('不缩进'); + expect([...container.querySelectorAll('p[style]')].map((paragraph) => paragraph.style.textIndent)) + .toEqual(['0em', '0em']); +}); diff --git a/app/flowix-web/features/preferences/sections/format.tsx b/app/flowix-web/features/preferences/sections/format.tsx index 8094ae551..da894f97b 100644 --- a/app/flowix-web/features/preferences/sections/format.tsx +++ b/app/flowix-web/features/preferences/sections/format.tsx @@ -22,6 +22,7 @@ import { DOCUMENT_WIDTH_STEP, DOCUMENT_WIDTH_FULL_THRESHOLD, DEFAULT_USER_SETTINGS, + type FormatConfig, } from '@/lib/constants'; import { FieldRow, SectionHeader } from '@features/preferences/sections/primitives'; import { useI18n } from '@/lib/i18n'; @@ -41,21 +42,9 @@ import { } from '@features/preferences/font-cache'; interface FormatSectionProps { - settings: { - fontFamily: string; - fontId?: string; - fontSize: number; - lineHeight: number; - documentWidth: number; - }; + settings: FormatConfig; updateSettings: (updates: { - format?: Partial<{ - fontFamily: string; - fontId?: string; - fontSize: number; - lineHeight: number; - documentWidth: number; - }>; + format?: Partial; }) => Promise; } @@ -204,10 +193,10 @@ export function FormatSection({ settings, updateSettings }: FormatSectionProps) lineHeight: settings.lineHeight, }} > -

+

The quick brown fox jumps over the lazy dog.

-

+

{t('preferences.format.previewText')}

@@ -318,6 +307,27 @@ export function FormatSection({ settings, updateSettings }: FormatSectionProps) /> + + + + {/* Reset */}