feat(settings): manage shared interface and editor fonts

This commit is contained in:
eezd committed 2026-10-24 20:14:48 +08:00
1 parent 1d5637a5eb
commit 1882312435
19 files changed
+849 -16

No files matched your search

+113
View File
@@ -0,0 +1,113 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { api } from './api'
import { customFonts, loadFonts } from './fonts'
import { FONT_PRESETS } from '../themes/typography'
import { loadSettings, settings, SettingsAppearanceError, updateSettings, type Settings } from './settings'
import { toast } from './workspace'
import Icon from './Icon.vue'
const props = defineProps<{ id: string; setting: 'uiFont' | 'textFont' | 'codeFont' }>()
const expanded = ref(false)
const busy = ref(false)
const error = ref('')
const fileInput = ref<HTMLInputElement>()
const fonts = computed(() => [
...Object.entries(FONT_PRESETS).filter(([id]) => !settings.hiddenFonts.includes(id)).map(([id, font]) => ({ id, name: font.label })),
...customFonts.value,
])
const selected = computed(() => fonts.value.find(font => font.id === settings[props.setting]) ?? { id: 'maple', name: FONT_PRESETS.maple.label })
async function choose(id: string) {
error.value = ''
busy.value = true
try { if (await save({ [props.setting]: id })) expanded.value = false }
finally { busy.value = false }
}
async function save(patch: Partial<Settings>) {
try { await updateSettings(patch); return true }
catch (cause) {
if (cause instanceof SettingsAppearanceError) { toast(cause.message); return true }
error.value = cause instanceof Error ? cause.message : String(cause)
try { await loadSettings() }
catch (reload) { error.value += ';重新读取设置失败:' + String(reload) }
return false
}
}
async function importFont(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
busy.value = true
error.value = ''
try {
if (file.size > 30 * 1024 * 1024) throw new Error('字体文件超过 30 MB')
if (!/\.(ttf|otf|woff2?)$/i.test(file.name)) throw new Error('请选择 TTF、OTF、WOFF 或 WOFF2 字体')
// 让浏览器校验实际字体;无效字体不会上传或进入页面的字体加载集合。
const data = await file.arrayBuffer()
try { await new FontFace('rawmark-import-check', data).load() }
catch (cause) { throw new Error('无法解析字体文件,请选择有效的字体。', { cause }) }
const font = await api.importFont(file)
await loadFonts()
if (!await save({ [props.setting]: font.id })) return
expanded.value = false
toast('字体已导入')
} catch (cause) { error.value = cause instanceof Error ? cause.message : String(cause) }
finally { busy.value = false }
}
async function remove(id: string) {
busy.value = true
error.value = ''
try {
if (Object.hasOwn(FONT_PRESETS, id)) {
const patch: Partial<typeof settings> = { hiddenFonts: [...settings.hiddenFonts, id] }
for (const key of ['uiFont', 'textFont', 'codeFont'] as const) if (settings[key] === id) patch[key] = 'maple'
if (!await save(patch)) return
} else {
await api.deleteFont(id)
await loadSettings()
}
toast('字体已删除,使用它的位置已恢复 Maple')
} catch (cause) { error.value = cause instanceof Error ? cause.message : String(cause) }
finally { busy.value = false }
}
function onKey(event: KeyboardEvent) {
if (event.key === 'Escape' && expanded.value) { event.stopPropagation(); expanded.value = false }
}
</script>
<template>
<div class="rm-font-picker" @keydown="onKey">
<button :id="id" class="rm-btn rm-font-trigger" :aria-expanded="expanded" :aria-controls="id + '-options'" :disabled="busy" @click="expanded = !expanded"><span>{{ selected.name }}</span><Icon name="chevron" /></button>
<div v-if="expanded" :id="id + '-options'" class="rm-font-options">
<div v-for="font in fonts" :key="font.id" class="rm-font-option">
<button class="rm-font-choice" :class="{ on: font.id === settings[setting] }" :aria-pressed="font.id === settings[setting]" :disabled="busy" @click="choose(font.id)"><span>{{ font.name }}</span><small v-if="font.id === 'maple'">默认</small></button>
<button v-if="font.id !== 'maple'" class="rm-icon-btn" :aria-label="'删除字体 ' + font.name" title="删除字体" :disabled="busy" @click="remove(font.id)"><Icon name="trash" /></button>
</div>
<button class="rm-btn rm-font-import" :disabled="busy" @click="fileInput!.click()"><Icon name="import" />{{ busy ? '处理中…' : '导入字体' }}</button>
<p class="rm-share-note">支持 TTF、OTF、WOFF、WOFF2,单个文件不超过 30 MB。</p>
</div>
<input ref="fileInput" type="file" accept=".ttf,.otf,.woff,.woff2" hidden :aria-label="'导入字体 ' + id" @change="importFont" />
<p v-if="error" class="rm-auth-error" role="alert">{{ error }}</p>
</div>
</template>
<style scoped>
.rm-font-picker { position: relative; width: 260px; max-width: 100%; min-width: 0; }
.rm-font-trigger { width: 100%; justify-content: space-between; text-align: left; }
.rm-font-trigger span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rm-font-options { position: absolute; top: calc(100% + 4px); right: 0; z-index: 10; width: 100%; box-sizing: border-box; border: 1px solid var(--rm-divider); border-radius: 8px; padding: var(--rm-space-1); max-height: min(320px, 45dvh); overflow: auto; background: var(--rm-bg); box-shadow: var(--rm-shadow); }
.rm-font-option { display: flex; align-items: center; gap: var(--rm-space-1); }
.rm-font-choice { display: flex; align-items: center; flex: 1; min-width: 0; padding: 6px 8px; text-align: left; overflow-wrap: anywhere; min-height: var(--rm-control-size); border: 0; border-radius: 4px; background: transparent; color: inherit; font: inherit; cursor: pointer; }
.rm-font-choice span { flex: 1; min-width: 0; }
.rm-font-choice:hover, .rm-font-choice.on { background: var(--rm-hover); }
.rm-font-choice small { flex: none; margin-left: 8px; color: var(--rm-text-muted); white-space: nowrap; }
.rm-font-import { width: 100%; margin-top: var(--rm-space-1); }
.rm-font-options p { margin: var(--rm-space-2); }
@media (pointer: coarse) { .rm-font-choice { min-height: 44px; } }
</style>
+5 -3
View File
@@ -4,7 +4,8 @@ import { api, ApiError } from './api'
import { runCommand } from './commands'
import Icon from './Icon.vue'
import AppearanceSettings from './AppearanceSettings.vue'
import { CODE_FONTS, TEXT_FONTS, UI_FONT_SIZE } from '../themes/typography'
import { UI_FONT_SIZE } from '../themes/typography'
import FontPicker from './FontPicker.vue'
import PluginsPanel from './PluginsPanel.vue'
import { installApp, installPrompt } from './pwa'
import { CONTENT_WIDTHS, DEFAULTS, TAB_SIZES, loadSettings, settings, settingsSave, SettingsAppearanceError, updateSettings, type Settings } from './settings'
@@ -164,6 +165,7 @@ async function changePassword() {
<div v-show="!searching" class="rm-settings-page">
<PluginsPanel v-if="section === 'plugins'" />
<template v-else-if="section === 'appearance'">
<SettingRow id="ui-font" control="rm-ui-font" resettable @reset="change({ uiFont: DEFAULTS.uiFont })"><FontPicker id="rm-ui-font" setting="uiFont" /></SettingRow>
<SettingRow id="ui-font-size" control="rm-ui-font-size" resettable @reset="change({ uiFontSize: DEFAULTS.uiFontSize })">
<label class="rm-font-input"><input id="rm-ui-font-size" type="number" :min="UI_FONT_SIZE.min" :max="UI_FONT_SIZE.max" step="1" :value="settings.uiFontSize" @blur="changeUIFont" @keydown.enter="($event.target as HTMLInputElement).blur()" /> px</label>
</SettingRow>
@@ -173,8 +175,8 @@ async function changePassword() {
<template v-else-if="section === 'editor'">
<h3 class="rm-settings-group-title">正文与代码</h3>
<SettingRow id="text-font" control="rm-text-font" resettable @reset="change({ textFont: DEFAULTS.textFont })"><select id="rm-text-font" :value="settings.textFont" @change="change({ textFont: ($event.target as HTMLSelectElement).value as Settings['textFont'] })"><option v-for="(font, id) in TEXT_FONTS" :key="id" :value="id">{{ font.label }}</option></select></SettingRow>
<SettingRow id="code-font" control="rm-code-font" resettable @reset="change({ codeFont: DEFAULTS.codeFont })"><select id="rm-code-font" :value="settings.codeFont" @change="change({ codeFont: ($event.target as HTMLSelectElement).value as Settings['codeFont'] })"><option v-for="(font, id) in CODE_FONTS" :key="id" :value="id">{{ font.label }}</option></select></SettingRow>
<SettingRow id="text-font" control="rm-text-font" resettable @reset="change({ textFont: DEFAULTS.textFont })"><FontPicker id="rm-text-font" setting="textFont" /></SettingRow>
<SettingRow id="code-font" control="rm-code-font" resettable @reset="change({ codeFont: DEFAULTS.codeFont })"><FontPicker id="rm-code-font" setting="codeFont" /></SettingRow>
<SettingRow id="font-size" control="rm-font-size" resettable @reset="change({ fontSize: DEFAULTS.fontSize })"><label class="rm-font-input"><input id="rm-font-size" type="number" min="0" step="any" :value="settings.fontSize" @input="changeFont" @blur="changeFont" @keydown.enter="($event.target as HTMLInputElement).blur()" /> px</label></SettingRow>
<SettingRow id="content-width" resettable @reset="change({ contentWidth: DEFAULTS.contentWidth })"><div class="rm-segmented" role="radiogroup" aria-label="正文宽度"><button v-for="w in CONTENT_WIDTHS" :key="w.value" role="radio" :aria-checked="settings.contentWidth === w.value" :class="{ on: settings.contentWidth === w.value }" @click="change({ contentWidth: w.value })">{{ w.label }}</button></div></SettingRow>
<SettingRow id="line-height" control="rm-line-height" resettable @reset="change({ lineHeight: DEFAULTS.lineHeight })"><input id="rm-line-height" type="range" min="1.2" max="2.5" step="0.05" :value="settings.lineHeight" @change="change({ lineHeight: Number(($event.target as HTMLInputElement).value) })" /><output for="rm-line-height">{{ settings.lineHeight.toFixed(2) }}</output></SettingRow>
+9 -1
View File
@@ -24,6 +24,8 @@ import { flushAll, readyToReload, startWorkspace, stopWorkspace, toast, ws } fro
const emit = defineEmits<{ logout: [] }>()
const title = computed(() => (ws.active ? noteTitle(ws.active) : 'RawMark'))
const resizingSidebar = ref(false)
// 自定义字体需登录后读取;初始化期间保留排版,但不先显示默认字体。
const initializing = ref(true)
// 拖动侧栏右缘调整宽度:拖动中只改 CSS 变量,松手后保存到设置
function startResize(e: PointerEvent) {
@@ -91,7 +93,11 @@ function onTouchMove(e: TouchEvent) {
const unregisterLogout = registerCommand({ id: 'app.logout', title: '退出登录', run: () => void logout() })
onMounted(() => void startWorkspace().catch((err) => toast('工作区初始化失败,请重新加载:' + (err instanceof ApiError ? err.message : '网络或本地存储错误'))))
onMounted(async () => {
try { await startWorkspace() }
catch (err) { toast('工作区初始化失败,请重新加载:' + (err instanceof ApiError ? err.message : '网络或本地存储错误')) }
finally { initializing.value = false }
})
onBeforeUnmount(() => {
unregisterLogout()
stopWorkspace()
@@ -101,6 +107,8 @@ onBeforeUnmount(() => {
<template>
<div
class="rm-shell"
:style="initializing ? { opacity: 0, pointerEvents: 'none' } : undefined"
:inert="initializing"
:class="{ 'sidebar-open': ws.sidebarOpen, 'sidebar-collapsed': ws.sidebarCollapsed, 'sidebar-resizing': resizingSidebar, 'focus-mode': ws.focusMode }"
@touchstart.passive="onTouchStart"
@touchmove.passive="onTouchMove"
+13
View File
@@ -35,6 +35,12 @@ export interface Theme {
version: string
}
export interface CustomFont {
id: string
name: string
format: 'ttf' | 'otf' | 'woff' | 'woff2'
}
export interface InstalledPlugin {
id: string
name: string
@@ -123,6 +129,13 @@ export const api = {
changePassword: (current: string, next: string) => request('POST', '/api/v1/auth/password', { json: { current, next } }),
settings: () => json<Record<string, unknown>>(request('GET', '/api/v1/settings')),
fonts: async () => (await json<{ fonts: CustomFont[] }>(request('GET', '/api/v1/fonts'))).fonts,
importFont: (file: File) => {
const body = new FormData()
body.append('file', file)
return json<CustomFont>(request('POST', '/api/v1/fonts', { body }))
},
deleteFont: (id: string) => request('DELETE', '/api/v1/fonts/' + encodeURIComponent(id)),
themes: async () => (await json<{ themes: Theme[] }>(request('GET', '/api/v1/themes'))).themes,
theme: (id: string) => json<Theme & { css: string }>(request('GET', '/api/v1/themes/' + encodeURIComponent(id))),
plugins: async () => (await json<{ plugins: InstalledPlugin[] }>(request('GET', '/api/v1/plugins'))).plugins,
+21
View File
@@ -0,0 +1,21 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest'
import { applyFontCatalog, parseFontCatalog } from './fonts'
describe('导入字体资源边界', () => {
it('拒绝可插入CSS的标识和不支持的格式', () => {
for (const value of [null, {}, [{}], [{ id: "custom-');}body{color:red}", name: 'bad', format: 'woff2' }], [{ id: 'custom-' + 'a'.repeat(64), name: 'bad', format: 'exe' }]]) {
expect(() => parseFontCatalog(value)).toThrow('字体列表格式无效')
}
})
it('字体名称只供界面显示,不进入CSS;设置同步不重复替换相同字体声明', () => {
const fonts = parseFontCatalog([{ id: 'custom-' + 'a'.repeat(64), name: "bad');}body{color:red}", format: 'woff2' }])
applyFontCatalog(fonts)
const style = document.querySelector('#rm-custom-fonts')!
expect(style.textContent).not.toContain('body{color:red}')
const text = style.firstChild
applyFontCatalog(fonts)
expect(style.firstChild).toBe(text)
applyFontCatalog([])
})
})
+22
View File
@@ -0,0 +1,22 @@
import { shallowRef } from 'vue'
import { api, type CustomFont } from './api'
export const customFonts = shallowRef<CustomFont[]>([])
export function applyFontCatalog(fonts: CustomFont[]) {
customFonts.value = fonts
let style = document.getElementById('rm-custom-fonts')
if (!style) { style = document.createElement('style'); style.id = 'rm-custom-fonts'; document.head.append(style) }
// 名称仅用于界面;CSS 标识和资源路径只由服务端内容哈希构成。
const css = fonts.map(font => `@font-face { font-family: '${font.id}'; src: url('/api/v1/fonts/${font.id}/file'); font-display: block; }`).join('\n')
if (style.textContent !== css) style.textContent = css
}
export function parseFontCatalog(value: unknown): CustomFont[] {
if (!Array.isArray(value) || value.some(font => !font || typeof font !== 'object' || !/^custom-[a-f0-9]{64}$/.test(font.id) || typeof font.name !== 'string' || !['ttf', 'otf', 'woff', 'woff2'].includes(font.format))) throw new Error('字体列表格式无效')
return value
}
export async function loadFonts() {
applyFontCatalog(parseFontCatalog(await api.fonts()))
}
+73
View File
@@ -8,15 +8,18 @@ import ShortcutSettings from './ShortcutSettings.vue'
import { DEFAULTS, loadSettings, settings, settingsSave, SettingsAppearanceError, updateSettings } from './settings'
import { searchSettings } from './settingsCatalog'
import { ws } from './workspace'
import { applyFontCatalog } from './fonts'
let app: App | undefined
let host: HTMLElement | undefined
const cleanup: (() => void)[] = []
beforeEach(() => {
applyFontCatalog([])
Object.assign(settings, DEFAULTS, { colorMode: 'system', theme: 'default', cssSnippets: [], shortcuts: {} })
Object.assign(settingsSave, { pending: 0, result: 'idle', error: '' })
vi.spyOn(api, 'patchSettings').mockResolvedValue({})
vi.spyOn(api, 'settings').mockResolvedValue({})
vi.spyOn(api, 'fonts').mockResolvedValue([])
vi.spyOn(api, 'themes').mockResolvedValue([{ id: 'default', name: '默认', version: '1' }])
})
afterEach(() => {
@@ -70,6 +73,76 @@ describe('设置查找与重置', () => {
expect(host!.textContent).not.toContain('模板')
})
it('界面与编辑器共用字体管理,删除预设同步恢复使用位置,Maple不可删除', async () => {
settings.uiFont = settings.textFont = settings.codeFont = 'serif'
ws.settings = 'appearance'
mount(SettingsDialog)
host!.querySelector<HTMLButtonElement>('#rm-ui-font')!.click()
await nextTick()
expect(host!.querySelector('button[aria-label^="删除字体 Maple"]')).toBeNull()
host!.querySelector<HTMLButtonElement>('button[aria-label="删除字体 Noto Serif SC · 思源宋体"]')!.click()
await vi.waitFor(() => expect(settings.hiddenFonts).toContain('serif'))
expect(settings.uiFont).toBe('maple')
expect(settings.textFont).toBe('maple')
expect(settings.codeFont).toBe('maple')
const editor = [...host!.querySelectorAll<HTMLButtonElement>('.rm-settings-tab')].find(button => button.textContent === '编辑器')!
editor.click()
await nextTick()
host!.querySelector<HTMLButtonElement>('#rm-text-font')!.click()
await nextTick()
expect(host!.querySelector('.rm-font-options')!.textContent).not.toContain('Noto Serif SC')
expect(host!.querySelector('.rm-font-options')!.textContent).toContain('导入字体')
})
it('先装载字体资源再恢复服务器上的自定义字体,字体删除后恢复Maple', async () => {
const font = { id: 'custom-' + 'a'.repeat(64), name: '导入字体', format: 'woff2' as const }
vi.mocked(api.fonts).mockResolvedValue([font])
vi.mocked(api.settings).mockResolvedValue({ uiFont: font.id, textFont: font.id, codeFont: font.id })
await loadSettings()
expect(settings.uiFont).toBe(font.id)
expect(document.querySelector('#rm-custom-fonts')!.textContent).toContain('/api/v1/fonts/' + font.id + '/file')
expect(document.documentElement.style.getPropertyValue('--rm-font-text')).toContain(font.id)
vi.mocked(api.fonts).mockResolvedValue([])
await loadSettings()
expect(settings.uiFont).toBe('maple')
expect(settings.textFont).toBe('maple')
expect(settings.codeFont).toBe('maple')
})
it('字体保存失败后重新读取原选择并显示错误', async () => {
vi.mocked(api.patchSettings).mockRejectedValueOnce(new ApiError(500, '字体设置写入失败'))
ws.settings = 'appearance'
mount(SettingsDialog)
host!.querySelector<HTMLButtonElement>('#rm-ui-font')!.click()
await nextTick()
const serif = [...host!.querySelectorAll<HTMLButtonElement>('.rm-font-choice')].find(button => button.textContent === 'Noto Serif SC · 思源宋体')!
serif.click()
await vi.waitFor(() => expect(host!.querySelector('[role="alert"]')!.textContent).toContain('字体设置写入失败'))
expect(settings.uiFont).toBe('maple')
expect(host!.querySelector<HTMLButtonElement>('#rm-ui-font')!.textContent).toContain('Maple')
})
it('删除字体失败保留原字体和设置,允许重试', async () => {
const font = { id: 'custom-' + 'b'.repeat(64), name: '不可删除测试', format: 'woff2' as const }
applyFontCatalog([font])
settings.uiFont = font.id
vi.spyOn(api, 'deleteFont').mockRejectedValueOnce(new ApiError(500, '字体删除失败'))
ws.settings = 'appearance'
mount(SettingsDialog)
host!.querySelector<HTMLButtonElement>('#rm-ui-font')!.click()
await nextTick()
host!.querySelector<HTMLButtonElement>('button[aria-label="删除字体 不可删除测试"]')!.click()
await vi.waitFor(() => expect(host!.querySelector('[role="alert"]')!.textContent).toContain('字体删除失败'))
expect(settings.uiFont).toBe(font.id)
expect(host!.querySelector<HTMLButtonElement>('#rm-ui-font')!.disabled).toBe(false)
})
it('忽略隐藏Maple的外部设置,不允许移除默认字体', async () => {
vi.mocked(api.settings).mockResolvedValue({ hiddenFonts: ['maple', 'serif'], uiFont: 'serif' })
await loadSettings()
expect(settings.hiddenFonts).toEqual(['serif'])
expect(settings.uiFont).toBe('maple')
})
it('设置和命令不再提供分享入口', async () => {
expect(searchSettings('分享')).toEqual([])
expect(allCommands().map(command => command.id)).not.toContain('note.share')
+9 -4
View File
@@ -1,7 +1,8 @@
// 应用设置:服务器上一份 JSON,全部设备共用;修改立即保存,其他设备经事件推送同步。
// 本地缓存上次的值,启动时先套用,避免页面先按默认值显示再跳变
import { reactive } from 'vue'
import { DISPLAY_DEFAULTS, sanitizeDisplaySettings, applyDisplaySettings } from '../themes/typography'
import { applyFontCatalog, customFonts, parseFontCatalog } from './fonts'
import { DISPLAY_DEFAULTS, FONT_PRESETS, sanitizeDisplaySettings, applyDisplaySettings } from '../themes/typography'
import { api } from './api'
import { normalizeKeys } from '../keys'
import { applyAppearance, appearancePaused, sanitizeAppearance, type Appearance } from '../themes/appearance'
@@ -17,7 +18,7 @@ export const CONTENT_WIDTHS = [
export const SIDEBAR_WIDTH = { min: 200, max: 480 }
export const DEFAULTS = { selectionToolbar: true, ...DISPLAY_DEFAULTS, sidebarWidth: 288, spellcheck: false, tabSize: 4 }
export const DEFAULTS = { hiddenFonts: [] as string[], selectionToolbar: true, ...DISPLAY_DEFAULTS, sidebarWidth: 288, spellcheck: false, tabSize: 4 }
export type Settings = typeof DEFAULTS & Appearance & { shortcuts: Record<string, string[]> }
const CACHE_KEY = 'rawmark.settings'
@@ -35,8 +36,10 @@ export class SettingsAppearanceError extends Error {
// 服务器上的设置可能由旧版本、API 或插件写入,取值不合法的项退回默认值
function sanitize(raw: Record<string, unknown>): Settings {
const pick = <T>(v: unknown, allowed: T[], def: T) => (allowed.includes(v as T) ? (v as T) : def)
const hiddenFonts = Array.isArray(raw.hiddenFonts) ? raw.hiddenFonts.filter((id): id is string => typeof id === 'string' && id !== 'maple' && Object.hasOwn(FONT_PRESETS, id)) : []
return {
...sanitizeDisplaySettings(raw),
hiddenFonts,
...sanitizeDisplaySettings(raw, customFonts.value.map(font => font.id), hiddenFonts),
selectionToolbar: typeof raw.selectionToolbar === 'boolean' ? raw.selectionToolbar : true,
shortcuts: sanitizeShortcuts(raw.shortcuts),
spellcheck: typeof raw.spellcheck === 'boolean' ? raw.spellcheck : DEFAULTS.spellcheck,
@@ -92,7 +95,9 @@ applied({ ...settings })
applyAppearance({ ...settings, theme: 'default', cssSnippets: [] }, '')
export async function loadSettings() {
applied(sanitize(await api.settings()))
const [raw, fonts] = await Promise.all([api.settings(), api.fonts()])
applyFontCatalog(parseFontCatalog(fonts))
applied(sanitize(raw))
await applySettingsAppearance()
}
+1
View File
@@ -13,6 +13,7 @@ export const SETTINGS_SECTIONS: { id: SettingsSection; title: string; icon: Icon
]
export const SETTING_ITEMS = {
'ui-font': { section: 'appearance', title: '界面字体', description: '用于菜单、侧栏与按钮;可导入或删除字体,Maple 为不可删除的默认字体。', keywords: 'UI 字体 导入 删除 TTF OTF WOFF' },
'ui-font-size': { section: 'appearance', title: '界面字号', description: '调整菜单、侧栏与按钮文字,不影响正文和代码字号。', keywords: 'UI 大小' },
'theme-select': { section: 'appearance', title: '主题', description: '使用内置主题,或从服务器数据目录的 themes 文件夹添加主题。', keywords: '白色 黑色 自定义' },
'color-mode': { section: 'appearance', title: '明暗模式', description: '选择浅色、深色,或跟随系统切换。', keywords: '黑暗 夜间 light dark system' },
+3
View File
@@ -24,6 +24,7 @@ describe('事件同步', () => {
it('目录移动映射整个已打开的子树,并把未保存输入写入新路径', async () => {
const tree = vi.spyOn(api, 'tree').mockResolvedValue([{ path: 'from/note.md', dir: false, size: 0, mtime: 0 }])
vi.spyOn(api, 'settings').mockResolvedValue({})
vi.spyOn(api, 'fonts').mockResolvedValue([])
vi.spyOn(api, 'plugins').mockResolvedValue([])
vi.spyOn(drafts, 'all').mockResolvedValue([])
vi.spyOn(drafts, 'put').mockImplementation(async (draft) => draft.path)
@@ -48,6 +49,7 @@ describe('事件同步', () => {
const entries = [{ path: 'sync.md', dir: false, size: 0, mtime: 0 }]
vi.spyOn(api, 'tree').mockResolvedValue(entries)
vi.spyOn(api, 'settings').mockResolvedValue({})
vi.spyOn(api, 'fonts').mockResolvedValue([])
vi.spyOn(api, 'plugins').mockResolvedValue([])
vi.spyOn(drafts, 'all').mockResolvedValue([])
vi.spyOn(drafts, 'delete').mockResolvedValue(undefined)
@@ -140,6 +142,7 @@ describe('笔记打开与创建', () => {
expect(ws.toasts.some(toast => toast.text.includes('打开失败:文件不存在'))).toBe(false)
})
})
describe('保存与关闭', () => {
+1 -1
View File
@@ -1037,7 +1037,7 @@ export async function startWorkspace() {
)
// 字号、正文宽度变化后行高随之改变,编辑器需要重新测量
watch(
() => [settings.uiFontSize, settings.fontSize, settings.contentWidth, settings.sidebarWidth, settings.textFont, settings.codeFont, settings.lineHeight, settings.paragraphSpacing],
() => [settings.uiFont, settings.uiFontSize, settings.fontSize, settings.contentWidth, settings.sidebarWidth, settings.textFont, settings.codeFont, settings.lineHeight, settings.paragraphSpacing],
() => view?.requestMeasure(),
)
watch(