Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions app/flowix-desktop/src/config/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,24 @@ 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 会用默�?值�?盖�?
#[serde(default)]
pub document_width: f64,
}

fn deserialize_first_line_indent<'de, D>(deserializer: D) -> Result<u8, D::Error>
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 {
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<ApplyFormat format={undefined} />));
await act(async () => root.render(<ApplyFormat format={{ ...DEFAULT_USER_SETTINGS.format, firstLineIndent: 2 }} />));

expect(document.documentElement.style.getPropertyValue('--editor-first-line-indent')).toBe('2em');

await act(async () => root.render(<ApplyFormat format={DEFAULT_USER_SETTINGS.format} />));

expect(document.documentElement.style.getPropertyValue('--editor-first-line-indent')).toBe('0em');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
} from '@features/preferences/font-cache';

/**
* 将用户在「Preferences → Format」中选择的字体/字号/行间距/文档宽度写入 :root
* 将用户在「Preferences → Format」中选择的字体/字号/行间距/首行缩进/文档宽度写入 :root
* 的 CSS 变量。
*
* 变量写入 :root 是为了 hook 点稳定, 但消费方 (css/editor.css 的
Expand All @@ -22,6 +22,7 @@ import {
* --editor-font-family
* --editor-font-size
* --editor-line-height
* --editor-first-line-indent
* --editor-document-width
*
* 调用方: app.tsx (主窗口 + 偏好设置窗口都会挂载, 因此跨窗口都会立即响应)。
Expand Down Expand Up @@ -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]);
}
70 changes: 70 additions & 0 deletions app/flowix-web/features/preferences/sections/format.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import('@features/preferences/font-cache')>(),
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 (
<I18nProvider language="zh-CN">
<FormatSection
settings={format}
updateSettings={async (updates: { format?: Partial<FormatConfig> }) => {
updateSettings(updates);
setFormat((current) => ({ ...current, ...updates.format }));
}}
/>
</I18nProvider>
);
}

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(<FormatPreferences />));
const trigger = container.querySelector<HTMLButtonElement>('#preferences-format-first-line-indent');
expect(trigger?.textContent).toBe('不缩进');

await act(async () => trigger!.click());
const indentOption = [...document.querySelectorAll<HTMLButtonElement>('[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<HTMLParagraphElement>('p[style]')].map((paragraph) => paragraph.style.textIndent))
.toEqual(['2em', '2em']);

const reset = [...container.querySelectorAll<HTMLButtonElement>('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<HTMLParagraphElement>('p[style]')].map((paragraph) => paragraph.style.textIndent))
.toEqual(['0em', '0em']);
});
43 changes: 27 additions & 16 deletions app/flowix-web/features/preferences/sections/format.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<FormatConfig>;
}) => Promise<void>;
}

Expand Down Expand Up @@ -204,10 +193,10 @@ export function FormatSection({ settings, updateSettings }: FormatSectionProps)
lineHeight: settings.lineHeight,
}}
>
<p className="m-0">
<p className="m-0" style={{ textIndent: `${settings.firstLineIndent}em` }}>
The quick brown fox jumps over the lazy dog.
</p>
<p className="m-0 mt-2">
<p className="m-0 mt-2" style={{ textIndent: `${settings.firstLineIndent}em` }}>
{t('preferences.format.previewText')}
</p>
</div>
Expand Down Expand Up @@ -318,6 +307,27 @@ export function FormatSection({ settings, updateSettings }: FormatSectionProps)
/>
</FieldRow>

<FieldRow
title={t('preferences.format.firstLineIndent.title')}
description={t('preferences.format.firstLineIndent.description')}
controlId="preferences-format-first-line-indent"
>
<Select
value={String(settings.firstLineIndent)}
onValueChange={(value) => updateSettings({ format: { firstLineIndent: value === '2' ? 2 : 0 } })}
>
<SelectTrigger id="preferences-format-first-line-indent" className="w-64">
{t(settings.firstLineIndent === 2
? 'preferences.format.firstLineIndent.twoCharacters'
: 'preferences.format.firstLineIndent.none')}
</SelectTrigger>
<SelectContent align="end" className="flowix-preferences-select-content w-64">
<SelectItem value="0">{t('preferences.format.firstLineIndent.none')}</SelectItem>
<SelectItem value="2">{t('preferences.format.firstLineIndent.twoCharacters')}</SelectItem>
</SelectContent>
</Select>
</FieldRow>

{/* Reset */}
<div className="flex justify-start">
<Button
Expand All @@ -330,6 +340,7 @@ export function FormatSection({ settings, updateSettings }: FormatSectionProps)
fontId: DEFAULT_USER_SETTINGS.format.fontId,
fontSize: DEFAULT_USER_SETTINGS.format.fontSize,
lineHeight: DEFAULT_USER_SETTINGS.format.lineHeight,
firstLineIndent: DEFAULT_USER_SETTINGS.format.firstLineIndent,
documentWidth: DEFAULT_USER_SETTINGS.format.documentWidth,
},
})
Expand Down
Loading
Loading