feat(ui): add typography preferences and contextual formatting

This commit is contained in:
eezd committed 2026-10-22 15:01:39 +08:00
1 parent 2a35a392b7
commit 06be0038cf
10 files changed
+145 -26

No files matched your search

+1 -1
View File
@@ -154,7 +154,7 @@ func (s *Server) publicAppearance(w http.ResponseWriter, r *http.Request) {
return
}
out := map[string]any{}
for _, key := range []string{"colorMode", "fontSize", "contentWidth"} {
for _, key := range []string{"colorMode", "fontSize", "contentWidth", "textFont", "codeFont", "lineHeight", "paragraphSpacing", "glassOpacity"} {
if value, ok := settings[key]; ok {
out[key] = value
}
+3 -1
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { api, ApiError, type Theme } from './api'
import { DISPLAY_DEFAULTS } from '../themes/typography'
import { appearancePaused, fixedThemeMode, type CssSnippet } from '../themes/appearance'
import { loadSettings, reloadTheme, settings, updateSettings, type Settings } from './settings'
import { toast } from './workspace'
@@ -65,7 +66,7 @@ async function remove(snippet: CssSnippet) {
}
async function reset() {
if (!await change({ colorMode: 'system', theme: 'default', fontSize: 16, contentWidth: 760,
if (!await change({ colorMode: 'system', theme: 'default', ...DISPLAY_DEFAULTS,
cssSnippets: settings.cssSnippets.map((s) => ({ ...s, enabled: false })) })) return
toast('已恢复默认外观,CSS 片段已停用并保留')
}
@@ -73,6 +74,7 @@ async function reset() {
<template>
<p v-if="appearancePaused" class="rm-appearance-note">已临时停用主题和 CSS。可恢复默认外观后重新打开页面。</p>
<div class="rm-theme-cards"><button v-for="mode in [{ id: 'light', title: '纯净浅色', subtitle: 'Serene · 日间写作' }, { id: 'dark', title: '沉静深色', subtitle: '沿用你的深色主题' }, { id: 'system', title: '跟随系统', subtitle: '随系统明暗切换' }]" :key="mode.id" :class="[mode.id, { on: (fixedMode ?? settings.colorMode) === mode.id }]" :disabled="busy" @click="change({ theme: 'default', colorMode: mode.id as Settings['colorMode'] })"><span class="rm-theme-sample"><i /><i /><i /></span><strong>{{ mode.title }}</strong><small>{{ mode.subtitle }}</small></button></div>
<div class="rm-setting">
<label for="rm-color-mode" class="rm-setting-label">明暗模式</label>
<select id="rm-color-mode" :value="fixedMode ?? settings.colorMode" :disabled="busy || !!fixedMode" @change="change({ colorMode: ($event.target as HTMLSelectElement).value as Settings['colorMode'] })">
+32 -7
View File
@@ -1,15 +1,18 @@
<script setup lang="ts">
import { indentLess, indentMore, redo, undo } from '@codemirror/commands'
import type { EditorView } from '@codemirror/view'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { cycleHeading, insertLink, toggleBlock, toggleBold, toggleCode, toggleItalic, toggleStrike } from '../editor/format'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { cycleHeading, insertLink, toggleBlock, toggleBold, toggleCode, toggleItalic, toggleStrike, toggleInline } from '../editor/format'
import Icon from './Icon.vue'
import { editorView, insertImagesFromPicker } from './workspace'
import { settings } from './settings'
import { activeTab, onEditorUpdate, ws, editorView, insertImagesFromPicker } from './workspace'
// 手机键盘上方的 Markdown 工具条:只在触屏设备上、编辑器获得焦点(软键盘弹出)时显示。
// 按钮在 mousedown 时阻止默认行为,焦点留在编辑器中,键盘不会收起
// (不能在 pointerdown 上阻止:WebKit 会因此不再触发 click)
const visible = ref(false)
const floating = ref<{ left: number; top: number } | null>(null)
let unsubscribe: (() => void) | undefined
const coarse = matchMedia('(pointer: coarse)')
const BUTTONS: { label: string; title: string; run: (v: EditorView) => unknown; cls?: string }[] = [
@@ -17,13 +20,14 @@ const BUTTONS: { label: string; title: string; run: (v: EditorView) => unknown;
{ label: 'B', title: '粗体', run: toggleBold, cls: 'bold' },
{ label: 'I', title: '斜体', run: toggleItalic, cls: 'italic' },
{ label: 'S', title: '删除线', run: toggleStrike, cls: 'strike' },
{ label: 'M', title: '高亮', run: toggleInline('==') },
{ label: '•', title: '无序列表', run: toggleBlock('bullet') },
{ label: '1.', title: '有序列表', run: toggleBlock('ordered') },
{ label: '☐', title: '任务列表', run: toggleBlock('task') },
{ label: '❝', title: '引用', run: toggleBlock('quote') },
{ label: '`', title: '行内代码', run: toggleCode, cls: 'mono' },
{ label: '🔗', title: '链接', run: insertLink },
{ label: '🖼', title: '插入图片', run: () => insertImagesFromPicker() },
{ label: '↗', title: '链接', run: insertLink },
{ label: '▧', title: '插入图片', run: () => insertImagesFromPicker() },
{ label: '⇥', title: '缩进', run: indentMore },
{ label: '⇤', title: '取消缩进', run: indentLess },
{ label: '↶', title: '撤销', run: undo },
@@ -41,17 +45,35 @@ function hideKeyboard() {
// 焦点从编辑器移到别处时,focusout 先于新元素获得焦点,等一帧再判断
function sync() {
requestAnimationFrame(() => (visible.value = coarse.matches && !!editorView()?.hasFocus))
requestAnimationFrame(() => {
const v = editorView()
visible.value = !!ws.active && activeTab()?.status === 'ready' && (!coarse.matches || !!v?.hasFocus)
floating.value = null
if (!v || coarse.matches || !settings.selectionToolbar || !v.hasFocus || v.state.selection.main.empty || ws.settings || ws.images !== null) return
const selection = v.state.selection.main
const coords = v.coordsAtPos(selection.head)
const rect = v.scrollDOM.getBoundingClientRect()
if (!coords || coords.bottom < rect.top || coords.top > rect.bottom) return
floating.value = { left: Math.max(12, Math.min(innerWidth - 248, coords.left - 120)), top: coords.top - 48 > rect.top ? coords.top - 48 : coords.bottom + 8 }
})
}
onMounted(() => {
unsubscribe = onEditorUpdate(sync)
document.addEventListener('scroll', sync, true)
window.addEventListener('resize', sync)
sync()
document.addEventListener('focusin', sync)
document.addEventListener('focusout', sync)
})
onBeforeUnmount(() => {
unsubscribe?.()
document.removeEventListener('scroll', sync, true)
window.removeEventListener('resize', sync)
document.removeEventListener('focusin', sync)
document.removeEventListener('focusout', sync)
})
watch(() => [ws.active, activeTab()?.status, settings.selectionToolbar, ws.settings, ws.images], sync)
</script>
<template>
@@ -71,8 +93,11 @@ onBeforeUnmount(() => {
{{ b.label }}
</button>
</div>
<button type="button" tabindex="-1" class="rm-format-hide" title="收起键盘" aria-label="收起键盘" @mousedown.prevent @click="hideKeyboard">
<button type="button" tabindex="-1" v-if="coarse.matches" class="rm-format-hide" title="收起键盘" aria-label="收起键盘" @mousedown.prevent @click="hideKeyboard">
<Icon name="chevron" class="down" />
</button>
</div>
<div v-if="floating && ws.view === 'notes'" class="rm-selection-toolbar" role="toolbar" aria-label="选中文本格式" :style="{ left: floating.left + 'px', top: floating.top + 'px' }">
<button v-for="b in BUTTONS.slice(0, 5).concat(BUTTONS.filter(button => button.title === '行内代码' || button.title === '链接'))" :key="b.title" :title="b.title" :aria-label="b.title" :class="b.cls" @mousedown.prevent @click="run(b.run)">{{ b.label }}</button>
</div>
</template>
+31 -5
View File
@@ -4,6 +4,7 @@ import { api, ApiError } from './api'
import { runCommand } from './commands'
import Icon, { type IconName } from './Icon.vue'
import AppearanceSettings from './AppearanceSettings.vue'
import { CODE_FONTS, TEXT_FONTS } from '../themes/typography'
import PluginsPanel from './PluginsPanel.vue'
import { installApp, installPrompt } from './pwa'
import { CONTENT_WIDTHS, TAB_SIZES, loadSettings, settings, updateSettings, type Settings } from './settings'
@@ -14,8 +15,8 @@ import TrashPanel from './TrashPanel.vue'
import { toast, ws, type SettingsSection } from './workspace'
const SECTIONS: { id: SettingsSection; title: string; icon: IconName }[] = [
{ id: 'appearance', title: '外观', icon: 'palette' },
{ id: 'editor', title: '编辑器', icon: 'edit' },
{ id: 'appearance', title: '常规与外观', icon: 'palette' },
{ id: 'editor', title: '编辑与阅读排版', icon: 'edit' },
{ id: 'plugins', title: '插件', icon: 'command' },
{ id: 'account', title: '账户', icon: 'user' },
{ id: 'shortcuts', title: '快捷键', icon: 'command' },
@@ -30,6 +31,8 @@ if (!buildVersion) throw new Error('缺少应用构建版本信息')
const section = computed(() => (ws.settings === 'index' ? 'appearance' : ws.settings!))
// 手机上一次只显示分组列表或分组内容
const dailyTemplates = computed(() => ws.entries.filter(entry => !entry.dir && entry.path.endsWith('.md')))
const missingDailyTemplate = computed(() => settings.dailyTemplate && !dailyTemplates.value.some(entry => entry.path === settings.dailyTemplate))
const showList = ref(ws.settings === 'index')
const current = computed(() => SECTIONS.find((s) => s.id === section.value)!)
@@ -53,7 +56,7 @@ async function change(patch: Partial<Settings>) {
await updateSettings(patch)
} catch (err) {
toast('保存设置失败:' + (err instanceof ApiError ? err.message : '网络错误,请检查连接'))
void loadSettings().catch(() => {})
void loadSettings().catch(error => toast('重新读取设置失败:' + (error instanceof Error ? error.message : '网络错误')))
}
}
@@ -68,6 +71,15 @@ function changeFont(event: Event) {
if (value !== settings.fontSize) void change({ fontSize: value })
}
function changeFolder(key: 'dailyFolder' | 'templatesFolder', event: Event) {
const input = event.target as HTMLInputElement
const path = input.value.trim()
if ((key === 'templatesFolder' && !path) || path && path.split('/').some(part => !part || part.startsWith('.') || /[\\\0]/.test(part))) {
toast('请输入有效的仓库相对目录'); input.value = settings[key]; return
}
void change({ [key]: path })
}
// ---- 修改登录密码 ----
const pw = ref({ current: '', next: '', confirm: '' })
const pwError = ref('')
@@ -95,23 +107,28 @@ async function changePassword() {
<div class="rm-dialog rm-settings" :class="{ 'show-list': showList }" role="dialog" aria-label="设置">
<nav class="rm-settings-nav">
<div class="rm-dialog-head">
<h2>设置</h2>
<h2>偏好设置中心<small>Personal Preferences Vault</small></h2>
<button class="rm-icon-btn rm-settings-close-list" title="关闭" @click="close"><Icon name="close" /></button>
</div>
<button v-for="s in SECTIONS" :key="s.id" class="rm-settings-tab" :class="{ on: s.id === section }" @click="open(s.id)">
<Icon :name="s.icon" />{{ s.title }}
</button>
<div class="rm-settings-nav-foot"><button v-if="installPrompt" class="rm-btn" @click="installApp">安装到桌面</button><small>RawMark · {{ buildVersion }}</small></div>
</nav>
<section class="rm-settings-body">
<div class="rm-dialog-head">
<button class="rm-icon-btn rm-settings-back" title="返回" @click="showList = true"><Icon name="chevron" class="flip" /></button>
<h2>{{ current.title }}</h2>
<div class="rm-settings-heading"><small>首选项 / {{ current.title }}</small><h2>{{ current.title }}</h2><p>让工作区适合你的写作习惯,修改即时保存。</p></div>
<button class="rm-icon-btn" title="关闭" @click="close"><Icon name="close" /></button>
</div>
<div class="rm-settings-content">
<PluginsPanel v-if="section === 'plugins'" />
<template v-else-if="section === 'appearance'">
<AppearanceSettings />
<div class="rm-setting"><label for="rm-glass-opacity" class="rm-setting-label">磨砂玻璃通透度</label><input id="rm-glass-opacity" type="range" min="0.4" max="1" step="0.05" :value="settings.glassOpacity" @change="change({ glassOpacity: Number(($event.target as HTMLInputElement).value) })" /><span>{{ Math.round(settings.glassOpacity * 100) }}%</span></div>
<h3 class="rm-settings-group-title">编辑与阅读排版</h3>
<div class="rm-setting"><label for="rm-text-font" class="rm-setting-label">正文主要字体</label><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></div>
<div class="rm-setting"><label for="rm-code-font" class="rm-setting-label">代码字体</label><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></div>
<div class="rm-setting">
<span class="rm-setting-label">字号</span>
<label class="rm-font-input"><input type="number" step="any" :value="settings.fontSize" aria-label="字号" @blur="changeFont" @keydown.enter="($event.target as HTMLInputElement).blur()" /> px</label>
@@ -131,6 +148,10 @@ async function changePassword() {
</button>
</div>
</div>
<div class="rm-setting"><label for="rm-line-height" class="rm-setting-label">正文行高</label><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) })" /><span>{{ settings.lineHeight.toFixed(2) }}</span></div>
<div class="rm-setting"><label for="rm-paragraph-spacing" class="rm-setting-label">段落间距</label><input id="rm-paragraph-spacing" type="range" min="0" max="40" step="2" :value="settings.paragraphSpacing" @change="change({ paragraphSpacing: Number(($event.target as HTMLInputElement).value) })" /><span>{{ settings.paragraphSpacing }}px</span></div>
<div class="rm-setting"><label for="rm-selection-toolbar" class="rm-setting-label">选中文本浮动工具栏</label><input id="rm-selection-toolbar" type="checkbox" :checked="settings.selectionToolbar" @change="change({ selectionToolbar: ($event.target as HTMLInputElement).checked })" /></div>
<article class="rm-typography-preview"><small>LIVE TYPOGRAPHY PREVIEW</small><h2>让文字自由呼吸</h2><p>用简洁的记录,连接每一个想法。Markdown 文件始终属于你。</p><p>真实内容,自然排版,专注当下的灵感。</p><code>const idea = 'Hello, RawMark'</code></article>
<p class="rm-share-note">设置保存在服务器上,所有设备共用。</p>
</template>
@@ -147,6 +168,11 @@ async function changePassword() {
</select>
</div>
<p class="rm-share-note">仅调整 Tab 字符的显示宽度,不改写正文。Tab 键仍按两个空格缩进。</p>
<h3 class="rm-settings-group-title">日记与模板</h3>
<div class="rm-setting"><label for="rm-daily-folder" class="rm-setting-label">日记目录</label><input id="rm-daily-folder" :value="settings.dailyFolder" placeholder="根目录" @change="changeFolder('dailyFolder', $event)" /></div>
<div class="rm-setting"><label for="rm-templates-folder" class="rm-setting-label">模板目录</label><input id="rm-templates-folder" :value="settings.templatesFolder" @change="changeFolder('templatesFolder', $event)" /></div>
<div class="rm-setting"><label for="rm-daily-template" class="rm-setting-label">日记模板</label><select id="rm-daily-template" :value="settings.dailyTemplate" @change="change({ dailyTemplate: ($event.target as HTMLSelectElement).value })"><option value="">空白笔记</option><option v-for="entry in dailyTemplates" :key="entry.path" :value="entry.path">{{ entry.path }}</option><option v-if="missingDailyTemplate" :value="settings.dailyTemplate">{{ settings.dailyTemplate }}(不存在)</option></select></div>
<p class="rm-share-note">日记使用本地日期命名,已有日记直接打开。模板支持 {{ '{{date}}' }}、{{ '{{time}}' }}、{{ '{{title}}' }}。</p>
<p class="rm-share-note">设置保存在服务器上,所有设备共用。</p>
</template>
+35
View File
@@ -632,3 +632,38 @@ button {
.rm-knowledge-nav { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; padding: 14px 12px 0; }
.rm-knowledge-nav button { display: flex; align-items: center; gap: 8px; padding: 9px 8px; font-size: 10px; border: 0; border-radius: 7px; background: none; color: var(--rm-text-muted); cursor: pointer; }
.rm-knowledge-nav button:hover, .rm-knowledge-nav .on { background: var(--rm-active); color: var(--rm-accent); }
/* 偏好设置沿用即时保存,在主画布中展开。 */
.rm-dialog-backdrop:has(.rm-settings) { top: 56px; padding: 28px; background: radial-gradient(ellipse at 70% 10%, var(--rm-selection), transparent 65%), var(--rm-bg); backdrop-filter: none; }
.rm-settings { max-width: 1250px; width: 100%; height: 100%; background: none; border: 0; border-radius: 0; box-shadow: none; gap: 28px; }
.rm-settings-nav { width: 228px; border: 1px solid var(--rm-divider); border-radius: 16px; padding: 18px 12px; background: color-mix(in srgb, var(--rm-sidebar-bg) calc(var(--rm-glass-opacity) * 100%), transparent); backdrop-filter: blur(18px); }
.rm-settings-nav .rm-dialog-head { padding: 8px 10px 24px; border-bottom: 1px solid var(--rm-divider); margin-bottom: 16px; }
.rm-settings-nav h2 { font-size: 13px; } .rm-settings-nav h2 small { display: block; font: 9px var(--rm-font-text); color: var(--rm-text-muted); margin-top: 6px; }
.rm-settings-tab { flex: none; font-size: 12px; min-height: 42px; color: var(--rm-text-muted); }
.rm-settings-tab.on { background: var(--rm-active); box-shadow: inset 3px 0 var(--rm-accent); }
.rm-settings-nav-foot { margin-top: auto; display: grid; gap: 12px; padding: 24px 10px 0; } .rm-settings-nav-foot small { font-size: 9px; color: var(--rm-text-muted); overflow-wrap: anywhere; }
.rm-settings-body > .rm-dialog-head { padding: 6px 2px 24px; align-items: flex-start; }
.rm-settings-heading small { font-size: 10px; color: var(--rm-accent); } .rm-settings-heading h2 { font: 600 26px var(--rm-font-heading); margin: 12px 0; } .rm-settings-heading p { font-size: 11px; color: var(--rm-text-muted); margin: 0; }
.rm-settings-content { border-radius: 16px; border: 1px solid var(--rm-divider); padding: 20px 8px; background: color-mix(in srgb, var(--rm-bg) calc(var(--rm-glass-opacity) * 100%), transparent); box-shadow: 0 3px 18px rgba(19,27,46,.025); }
.rm-setting { padding: 13px 20px; font-size: 12px; } .rm-setting-label { font-size: 12px; } .rm-setting input[type=range] { width: 145px; accent-color: var(--rm-accent); }
.rm-setting input:not([type=checkbox],[type=range]), .rm-setting select { max-width: 260px; min-width: 0; padding: 8px 10px; border: 1px solid var(--rm-divider); border-radius: 7px; background: var(--rm-bg); color: var(--rm-text); font: inherit; }
.rm-settings-group-title { font: 600 17px var(--rm-font-heading); border-top: 1px solid var(--rm-divider); margin: 26px 20px 10px; padding-top: 24px; }
.rm-theme-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; padding: 6px 20px 20px; }
.rm-theme-cards button { display: flex; flex-direction: column; gap: 8px; text-align: left; padding: 12px; border: 1px solid var(--rm-divider); border-radius: 12px; background: var(--rm-active); cursor: pointer; min-width: 0; }
.rm-theme-cards .on { outline: 2px solid var(--rm-accent); outline-offset: -1px; }
.rm-theme-cards strong { font-size: 11px; font-weight: 600; } .rm-theme-cards small { font-size: 9px; color: var(--rm-text-muted); }
.rm-theme-sample { width: 100%; height: 60px; border-radius: 7px; background: #f2f3ff; display: flex; flex-direction: column; justify-content: center; gap: 6px; padding: 12px; box-sizing: border-box; }
.rm-theme-sample i { width: 65%; height: 3px; background: #c7c4d8; border-radius: 4px; } .rm-theme-sample i:first-child { width: 45%; height: 5px; background: #4f46e5; } .dark .rm-theme-sample { background: #1e2022; } .dark .rm-theme-sample i:first-child { background: #42d392; } .system .rm-theme-sample { background: linear-gradient(120deg,#f2f3ff 50%,#1e2022 50%); }
.rm-typography-preview { margin: 20px; padding: 24px; border-radius: 12px; border: 1px solid var(--rm-divider); background: var(--rm-sidebar-bg); font-size: var(--rm-font-size); line-height: var(--rm-line-height); }
.rm-typography-preview > small { font-size: 8px; color: var(--rm-accent); letter-spacing: .12em; } .rm-typography-preview h2 { font-family: var(--rm-font-heading); font-size: 1.5em; } .rm-typography-preview p { margin-bottom: var(--rm-paragraph-spacing); } .rm-typography-preview code { font-family: var(--rm-font-mono); font-size: .75em; color: var(--rm-inline-code-text); }
@media(max-width: 767px) {
.rm-dialog-backdrop:has(.rm-settings) { top: 0; padding: 0; }
.rm-settings { gap: 0; } .rm-settings-nav { width: auto; border-radius: 0; padding: 20px 16px; } .rm-settings-body { padding: 16px; } .rm-settings-content { padding: 12px 0; } .rm-settings-heading h2 { font-size: 22px; } .rm-settings-body > .rm-dialog-head { gap: 10px; }
.rm-theme-cards { padding: 12px; gap: 8px; } .rm-theme-cards button { padding: 8px; } .rm-theme-cards small { font-size: 8px; }
.rm-setting { padding: 12px; } .rm-setting input:not([type=checkbox],[type=range]), .rm-setting select { font-size: 16px; max-width: 100%; } .rm-settings-tab { min-height: 48px; font-size: 14px; }
}
.rm-selection-toolbar { position: fixed; display: flex; align-items: center; padding: 4px; border: 1px solid var(--rm-divider); border-radius: 10px; background: color-mix(in srgb, var(--rm-bg) 90%, transparent); backdrop-filter: blur(18px); box-shadow: var(--rm-shadow); z-index: 20; }
.rm-selection-toolbar button { width: 32px; height: 32px; border: 0; border-radius: 6px; background: none; cursor: pointer; font: 13px var(--rm-font-text); } .rm-selection-toolbar button:hover { background: var(--rm-selection); color: var(--rm-accent); } .rm-selection-toolbar .bold { font-weight: 700; } .rm-selection-toolbar .italic { font-style: italic; } .rm-selection-toolbar .strike { text-decoration: line-through; }
@media(min-width:768px) { .rm-format-bar { position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%); width: max-content; max-width: calc(100% - 40px); height: 40px; padding: 4px 8px; border: 1px solid var(--rm-divider); border-radius: 12px; background: color-mix(in srgb, var(--rm-bg) 85%, transparent); backdrop-filter: blur(18px); box-shadow: var(--rm-shadow); z-index: 15; } .rm-format-bar button { min-width: 32px; height: 32px; font-size: 13px; border-radius: 5px; } .rm-format-scroll { align-items: center; } }
@media(max-width:767px) { .rm-format-bar { padding-bottom: env(safe-area-inset-bottom); } }
.rm-typography-preview { font-family: var(--rm-font-editor, var(--rm-font-text)); }
+5 -9
View File
@@ -1,6 +1,7 @@
// 应用设置:服务器上一份 JSON,全部设备共用;修改立即保存,其他设备经事件推送同步。
// 本地缓存上次的值,启动时先套用,避免页面先按默认值显示再跳变
import { reactive } from 'vue'
import { DISPLAY_DEFAULTS, sanitizeDisplaySettings, applyDisplaySettings } from '../themes/typography'
import { api } from './api'
import { normalizeKeys } from '../keys'
import { applyAppearance, appearancePaused, sanitizeAppearance, type Appearance } from '../themes/appearance'
@@ -16,7 +17,7 @@ export const CONTENT_WIDTHS = [
export const SIDEBAR_WIDTH = { min: 200, max: 480 }
const DEFAULTS = { dailyFolder: 'Daily', templatesFolder: 'Templates', dailyTemplate: '', fontSize: 16, contentWidth: 760, sidebarWidth: 288, spellcheck: false, tabSize: 4 }
const DEFAULTS = { dailyFolder: 'Daily', templatesFolder: 'Templates', dailyTemplate: '', 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'
@@ -30,16 +31,12 @@ function sanitize(raw: Record<string, unknown>): Settings {
dailyFolder: typeof raw.dailyFolder === 'string' ? raw.dailyFolder : DEFAULTS.dailyFolder,
templatesFolder: typeof raw.templatesFolder === 'string' && raw.templatesFolder !== '' ? raw.templatesFolder : DEFAULTS.templatesFolder,
dailyTemplate: typeof raw.dailyTemplate === 'string' ? raw.dailyTemplate : DEFAULTS.dailyTemplate,
fontSize: typeof raw.fontSize === 'number' && Number.isFinite(raw.fontSize) && raw.fontSize > 0 ? raw.fontSize : DEFAULTS.fontSize,
...sanitizeDisplaySettings(raw),
selectionToolbar: typeof raw.selectionToolbar === 'boolean' ? raw.selectionToolbar : true,
shortcuts: sanitizeShortcuts(raw.shortcuts),
spellcheck: typeof raw.spellcheck === 'boolean' ? raw.spellcheck : DEFAULTS.spellcheck,
tabSize: pick(raw.tabSize, TAB_SIZES, DEFAULTS.tabSize),
...sanitizeAppearance(raw),
contentWidth: pick(
raw.contentWidth,
CONTENT_WIDTHS.map((w) => w.value),
DEFAULTS.contentWidth,
),
sidebarWidth:
Number.isInteger(raw.sidebarWidth) && (raw.sidebarWidth as number) >= SIDEBAR_WIDTH.min && (raw.sidebarWidth as number) <= SIDEBAR_WIDTH.max
? (raw.sidebarWidth as number)
@@ -81,8 +78,7 @@ function applied(next: Settings) {
Object.assign(settings, next)
localStorage.setItem(CACHE_KEY, JSON.stringify(settings))
const root = document.documentElement.style
root.setProperty('--rm-font-size', settings.fontSize + 'px')
root.setProperty('--rm-content-width', settings.contentWidth ? settings.contentWidth + 'px' : 'none')
applyDisplaySettings(settings)
root.setProperty('--rm-sidebar-width', settings.sidebarWidth + 'px')
}
+2 -2
View File
@@ -1018,7 +1018,7 @@ export async function startWorkspace() {
(r) => localStorage.setItem(RECENT_KEY, JSON.stringify(r)),
)
watch(
() => ({ sidebarCollapsed: ws.sidebarCollapsed, outlineOpen: ws.outlineOpen }),
() => ({ sidebarCollapsed: ws.sidebarCollapsed, outlineOpen: ws.outlineOpen, focusMode: ws.focusMode }),
(l) => {
localStorage.setItem(LAYOUT_KEY, JSON.stringify(l))
// 编辑区宽度变化,需要重新测量
@@ -1027,7 +1027,7 @@ export async function startWorkspace() {
)
// 字号、正文宽度变化后行高随之改变,编辑器需要重新测量
watch(
() => [settings.fontSize, settings.contentWidth, settings.sidebarWidth],
() => [settings.fontSize, settings.contentWidth, settings.sidebarWidth, settings.textFont, settings.codeFont, settings.lineHeight, settings.paragraphSpacing],
() => view?.requestMeasure(),
)
watch(
+3
View File
@@ -124,6 +124,9 @@ function buildDecorations(view: EditorView): DecorationSet {
return
}
switch (name) {
case 'Paragraph':
if (!node.parent || !['ListItem', 'Blockquote'].includes(node.parent.name)) out.push(lineDeco('cm-rm-paragraph-end').range(doc.lineAt(node.to).from))
return
case 'HeaderMark': {
if (isActive(node.from)) return
const parent = node.parent!
+1 -1
View File
@@ -99,7 +99,7 @@ body {
outline: none;
}
.cm-editor .cm-scroller {
font-family: var(--rm-font-text);
font-family: var(--rm-font-editor, var(--rm-font-text));
}
.cm-editor .cm-content {
max-width: var(--rm-content-width);
+32
View File
@@ -0,0 +1,32 @@
// 编辑器和只读分享共用可公开的排版设置;账户、快捷键等设置不进入此模块。
export const TEXT_FONTS = {
sans: { label: 'Manrope · 清晰无衬线', css: "'Manrope', 'PingFang SC', 'Microsoft YaHei', sans-serif" },
serif: { label: 'Noto Serif SC · 思源宋体', css: "'Noto Serif SC', 'Source Han Serif SC', serif" },
system: { label: '系统字体', css: 'system-ui, sans-serif' },
}
export const CODE_FONTS = {
jetbrains: { label: 'JetBrains Mono', css: "'JetBrains Mono', ui-monospace, monospace" },
system: { label: '系统等宽字体', css: 'ui-monospace, monospace' },
}
export const DISPLAY_DEFAULTS = { fontSize: 16, contentWidth: 760, textFont: 'sans' as keyof typeof TEXT_FONTS, codeFont: 'jetbrains' as keyof typeof CODE_FONTS, lineHeight: 1.75, paragraphSpacing: 16, glassOpacity: .75 }
export type DisplaySettings = typeof DISPLAY_DEFAULTS
export function sanitizeDisplaySettings(raw: Record<string, unknown>): DisplaySettings {
const ranged = (key: 'lineHeight' | 'paragraphSpacing' | 'glassOpacity', min: number, max: number) => typeof raw[key] === 'number' && Number.isFinite(raw[key]) && raw[key] >= min && raw[key] <= max ? raw[key] : DISPLAY_DEFAULTS[key]
return {
fontSize: typeof raw.fontSize === 'number' && Number.isFinite(raw.fontSize) && raw.fontSize > 0 ? raw.fontSize : 16,
contentWidth: typeof raw.contentWidth === 'number' && [0, 680, 760, 960].includes(raw.contentWidth) ? raw.contentWidth : 760,
textFont: raw.textFont === 'serif' || raw.textFont === 'system' ? raw.textFont : 'sans',
codeFont: raw.codeFont === 'system' ? 'system' : 'jetbrains',
lineHeight: ranged('lineHeight', 1.2, 2.5), paragraphSpacing: ranged('paragraphSpacing', 0, 40), glassOpacity: ranged('glassOpacity', .4, 1),
}
}
export function applyDisplaySettings(settings: DisplaySettings) {
const root = document.documentElement.style
root.setProperty('--rm-font-size', settings.fontSize + 'px')
root.setProperty('--rm-content-width', settings.contentWidth ? settings.contentWidth + 'px' : 'none')
root.setProperty('--rm-font-editor', TEXT_FONTS[settings.textFont].css)
root.setProperty('--rm-font-mono', CODE_FONTS[settings.codeFont].css)
root.setProperty('--rm-line-height', String(settings.lineHeight))
root.setProperty('--rm-paragraph-spacing', settings.paragraphSpacing + 'px')
root.setProperty('--rm-glass-opacity', String(settings.glassOpacity))
}