feat(ui): add typography preferences and contextual formatting
This commit is contained in:
1 parent
2a35a392b7
commit
06be0038cf
10 files changed
+145
-26
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'] })">
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)); }
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
Reference in new issue
Block a user