Add synced themes, color modes and custom CSS snippets
This commit is contained in:
1 parent
1562519f08
commit
7f65162115
17 files changed
+766
-36
No files matched your search
@@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { api, ApiError, type Theme } from './api'
|
||||
import { appearancePaused, type CssSnippet } from '../themes/appearance'
|
||||
import { loadSettings, reloadTheme, settings, updateSettings, type Settings } from './settings'
|
||||
import { toast } from './workspace'
|
||||
|
||||
const themes = ref<Theme[]>([{ id: 'default', name: '默认', version: '1' }])
|
||||
const error = ref('')
|
||||
const busy = ref(false)
|
||||
const editing = ref<string | null>(null)
|
||||
const draft = ref({ name: '', css: '' })
|
||||
const missingTheme = computed(() => !themes.value.some((theme) => theme.id === settings.theme))
|
||||
|
||||
async function refreshThemes() {
|
||||
error.value = ''
|
||||
try {
|
||||
themes.value = await api.themes()
|
||||
await reloadTheme()
|
||||
}
|
||||
catch (err) { error.value = err instanceof ApiError ? err.message : '读取主题失败,请检查网络' }
|
||||
}
|
||||
onMounted(refreshThemes)
|
||||
|
||||
async function change(patch: Partial<Settings>): Promise<boolean> {
|
||||
busy.value = true
|
||||
try {
|
||||
await updateSettings(patch)
|
||||
return true
|
||||
} catch (err) {
|
||||
toast('保存外观失败:' + (err instanceof ApiError ? err.message : '网络错误,请检查连接'))
|
||||
void loadSettings().catch((err) => toast('重新读取外观失败:' + (err instanceof ApiError ? err.message : '网络错误')))
|
||||
return false
|
||||
} finally { busy.value = false }
|
||||
}
|
||||
|
||||
function edit(snippet?: CssSnippet) {
|
||||
editing.value = snippet?.id ?? ''
|
||||
draft.value = { name: snippet?.name ?? '', css: snippet?.css ?? '' }
|
||||
}
|
||||
|
||||
async function saveSnippet() {
|
||||
if (!draft.value.name.trim()) return
|
||||
const snippets = settings.cssSnippets.map((s) => ({ ...s }))
|
||||
const existing = snippets.find((s) => s.id === editing.value)
|
||||
if (existing) Object.assign(existing, draft.value, { name: draft.value.name.trim() })
|
||||
else {
|
||||
// getRandomValues 在局域网 HTTP 开发页面也可用,randomUUID 需要安全上下文。
|
||||
const id = [...crypto.getRandomValues(new Uint32Array(4))].map((n) => n.toString(16).padStart(8, '0')).join('')
|
||||
snippets.push({ ...draft.value, name: draft.value.name.trim(), id, enabled: true })
|
||||
}
|
||||
if (await change({ cssSnippets: snippets })) editing.value = null
|
||||
}
|
||||
|
||||
async function toggle(snippet: CssSnippet) {
|
||||
await change({ cssSnippets: settings.cssSnippets.map((s) => s.id === snippet.id ? { ...s, enabled: !s.enabled } : { ...s }) })
|
||||
}
|
||||
|
||||
async function remove(snippet: CssSnippet) {
|
||||
if (!window.confirm(`删除 CSS 片段“${snippet.name}”?`)) return
|
||||
if (await change({ cssSnippets: settings.cssSnippets.filter((s) => s.id !== snippet.id) })) {
|
||||
if (editing.value === snippet.id) editing.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function reset() {
|
||||
if (!await change({ colorMode: 'system', theme: 'default', fontSize: 16, contentWidth: 760,
|
||||
cssSnippets: settings.cssSnippets.map((s) => ({ ...s, enabled: false })) })) return
|
||||
toast('已恢复默认外观,CSS 片段已停用并保留')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p v-if="appearancePaused" class="rm-appearance-note">已临时停用主题和 CSS。可恢复默认外观后重新打开页面。</p>
|
||||
<div class="rm-setting">
|
||||
<label for="rm-color-mode" class="rm-setting-label">明暗模式</label>
|
||||
<select id="rm-color-mode" :value="settings.colorMode" :disabled="busy" @change="change({ colorMode: ($event.target as HTMLSelectElement).value as Settings['colorMode'] })">
|
||||
<option value="system">跟随系统</option><option value="light">浅色</option><option value="dark">深色</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="rm-setting">
|
||||
<label for="rm-theme-select" class="rm-setting-label">主题</label>
|
||||
<select id="rm-theme-select" :value="settings.theme" :disabled="busy" @change="change({ theme: ($event.target as HTMLSelectElement).value })">
|
||||
<option v-for="theme in themes" :key="theme.id" :value="theme.id">{{ theme.name }}</option>
|
||||
<option v-if="missingTheme" :value="settings.theme">{{ settings.theme }}(不可用)</option>
|
||||
</select>
|
||||
<button class="rm-btn" :disabled="busy" @click="refreshThemes">刷新列表</button>
|
||||
</div>
|
||||
<p v-if="error" class="rm-appearance-note rm-auth-error">{{ error }}</p>
|
||||
<p class="rm-appearance-note">主题从服务器数据目录的 themes 文件夹读取;主题和已启用的 CSS 也会应用到分享页。</p>
|
||||
<div class="rm-setting"><span class="rm-setting-label">CSS 片段</span><button class="rm-btn" :disabled="busy" @click="edit()">新建片段</button></div>
|
||||
<div v-for="snippet in settings.cssSnippets" :key="snippet.id" class="rm-snippet-row">
|
||||
<label><input type="checkbox" :checked="snippet.enabled" :disabled="busy" @change="toggle(snippet)" />{{ snippet.name }}</label>
|
||||
<button class="rm-btn" :disabled="busy" @click="edit(snippet)">编辑</button>
|
||||
<button class="rm-btn rm-btn-danger" :disabled="busy" @click="remove(snippet)">删除</button>
|
||||
</div>
|
||||
<form v-if="editing !== null" class="rm-snippet-form" @submit.prevent="saveSnippet">
|
||||
<label>片段名称<input v-model="draft.name" aria-label="片段名称" maxlength="100" required /></label>
|
||||
<label>CSS<textarea v-model="draft.css" aria-label="CSS" rows="9" spellcheck="false" placeholder=":root { --rm-accent: #805ad5; }" /></label>
|
||||
<div><button class="rm-btn" :disabled="busy" type="submit">保存并应用</button> <button class="rm-btn" type="button" @click="editing = null">取消</button></div>
|
||||
</form>
|
||||
<div class="rm-setting"><span class="rm-setting-label">默认外观</span><button class="rm-btn" :disabled="busy" @click="reset">恢复默认外观</button></div>
|
||||
<p class="rm-appearance-note">若自定义样式导致界面无法操作,可在地址末尾加 <code>?reset-appearance</code>,临时停用后再恢复默认。</p>
|
||||
</template>
|
||||
@@ -3,6 +3,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { api, ApiError } from './api'
|
||||
import { allCommands, formatKeys, runCommand } from './commands'
|
||||
import Icon, { type IconName } from './Icon.vue'
|
||||
import AppearanceSettings from './AppearanceSettings.vue'
|
||||
import { CONTENT_WIDTHS, FONT_SIZES, loadSettings, settings, updateSettings, type Settings } from './settings'
|
||||
import SharePanel from './SharePanel.vue'
|
||||
import TokensPanel from './TokensPanel.vue'
|
||||
@@ -106,6 +107,7 @@ async function changePassword() {
|
||||
</div>
|
||||
<div class="rm-settings-content">
|
||||
<template v-if="section === 'appearance'">
|
||||
<AppearanceSettings />
|
||||
<div class="rm-setting">
|
||||
<span class="rm-setting-label">字号</span>
|
||||
<select :value="settings.fontSize" aria-label="字号" @change="change({ fontSize: Number(($event.target as HTMLSelectElement).value) })">
|
||||
|
||||
@@ -7,6 +7,12 @@ export interface Entry {
|
||||
mtime: number
|
||||
}
|
||||
|
||||
export interface Theme {
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
}
|
||||
|
||||
export interface TrashItem {
|
||||
id: string
|
||||
path: string
|
||||
@@ -97,6 +103,8 @@ 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')),
|
||||
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))),
|
||||
// 按顶层键合并,值为 null 的键被删除
|
||||
patchSettings: (patch: Record<string, unknown>) => json<Record<string, unknown>>(request('PATCH', '/api/v1/settings', { json: patch })),
|
||||
|
||||
|
||||
+11
-21
@@ -1,25 +1,4 @@
|
||||
/* 应用外壳样式,颜色与字体沿用 themes/base.css 中的 --rm-* 变量 */
|
||||
:root {
|
||||
--rm-sidebar-bg: #f9f9f9;
|
||||
--rm-sidebar-width: 260px;
|
||||
--rm-hover: rgba(0, 0, 0, 0.045);
|
||||
--rm-active: rgba(66, 184, 131, 0.14);
|
||||
--rm-danger: #e5484d;
|
||||
--rm-warn-bg: #fff8e6;
|
||||
--rm-warn-text: #8a5a00;
|
||||
--rm-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--rm-sidebar-bg: #161618;
|
||||
--rm-hover: rgba(255, 255, 255, 0.06);
|
||||
--rm-active: rgba(66, 211, 146, 0.16);
|
||||
--rm-warn-bg: #2f2716;
|
||||
--rm-warn-text: #f0c060;
|
||||
--rm-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
@@ -534,3 +513,14 @@ button {
|
||||
.rm-switcher-item { height: 44px; font-size: 15px; }
|
||||
.rm-trash-list li { flex-wrap: wrap; }
|
||||
}
|
||||
|
||||
/* 主题与 CSS 编辑 */
|
||||
.rm-appearance-note { margin: 8px 20px 16px; font-size: 13px; color: var(--rm-text-muted); overflow-wrap: anywhere; }
|
||||
.rm-snippet-row { display: flex; align-items: center; gap: 8px; padding: 6px 20px; }
|
||||
.rm-snippet-row label { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; overflow-wrap: anywhere; font-size: 14px; }
|
||||
.rm-snippet-row input { accent-color: var(--rm-accent); flex: none; }
|
||||
.rm-snippet-form { display: flex; flex-direction: column; gap: 12px; padding: 12px 20px; }
|
||||
.rm-snippet-form label { display: flex; flex-direction: column; gap: 6px; font-size: 13px; }
|
||||
.rm-snippet-form input, .rm-snippet-form textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: 8px; border: 1px solid var(--rm-divider); border-radius: 6px; background: var(--rm-bg); color: var(--rm-text); font: inherit; }
|
||||
.rm-snippet-form textarea { font-family: var(--rm-font-mono); resize: vertical; }
|
||||
@media (pointer: coarse) { .rm-snippet-row .rm-btn, .rm-snippet-form .rm-btn { min-height: 44px; } .rm-snippet-form input, .rm-snippet-form textarea { font-size: 16px; } }
|
||||
+40
-2
@@ -2,6 +2,7 @@
|
||||
// 本地缓存上次的值,启动时先套用,避免页面先按默认值显示再跳变
|
||||
import { reactive } from 'vue'
|
||||
import { api } from './api'
|
||||
import { applyAppearance, appearancePaused, sanitizeAppearance, type Appearance } from '../themes/appearance'
|
||||
|
||||
export const FONT_SIZES = [14, 15, 16, 17, 18, 20, 22]
|
||||
// 0 为全宽
|
||||
@@ -15,7 +16,7 @@ export const CONTENT_WIDTHS = [
|
||||
export const SIDEBAR_WIDTH = { min: 200, max: 480 }
|
||||
|
||||
const DEFAULTS = { fontSize: 16, contentWidth: 760, sidebarWidth: 260 }
|
||||
export type Settings = typeof DEFAULTS
|
||||
export type Settings = typeof DEFAULTS & Appearance
|
||||
|
||||
const CACHE_KEY = 'rawmark.settings'
|
||||
|
||||
@@ -26,6 +27,7 @@ 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)
|
||||
return {
|
||||
fontSize: pick(raw.fontSize, FONT_SIZES, DEFAULTS.fontSize),
|
||||
...sanitizeAppearance(raw),
|
||||
contentWidth: pick(
|
||||
raw.contentWidth,
|
||||
CONTENT_WIDTHS.map((w) => w.value),
|
||||
@@ -38,6 +40,25 @@ function sanitize(raw: Record<string, unknown>): Settings {
|
||||
}
|
||||
}
|
||||
|
||||
let currentTheme = ''
|
||||
let currentCSS = ''
|
||||
let appearanceRequest = 0
|
||||
|
||||
async function applySettingsAppearance() {
|
||||
const request = ++appearanceRequest
|
||||
const theme = settings.theme
|
||||
if (appearancePaused || theme === 'default') {
|
||||
currentTheme = theme
|
||||
currentCSS = ''
|
||||
} else if (theme !== currentTheme) {
|
||||
const data = await api.theme(theme)
|
||||
if (request !== appearanceRequest) return
|
||||
currentCSS = data.css
|
||||
currentTheme = theme
|
||||
}
|
||||
applyAppearance({ ...settings }, currentCSS)
|
||||
}
|
||||
|
||||
function applied(next: Settings) {
|
||||
Object.assign(settings, next)
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(settings))
|
||||
@@ -48,13 +69,30 @@ function applied(next: Settings) {
|
||||
}
|
||||
|
||||
applied({ ...settings })
|
||||
// 缓存只提前应用明暗模式;主题和片段等服务器确认后再应用。
|
||||
applyAppearance({ ...settings, theme: 'default', cssSnippets: [] }, '')
|
||||
|
||||
export async function loadSettings() {
|
||||
applied(sanitize(await api.settings()))
|
||||
await applySettingsAppearance()
|
||||
}
|
||||
|
||||
export async function reloadTheme() {
|
||||
currentTheme = ''
|
||||
await applySettingsAppearance()
|
||||
}
|
||||
|
||||
/** 修改设置:先在本地生效再保存,保存失败时由调用方提示并重新载入 */
|
||||
export async function updateSettings(patch: Partial<Settings>) {
|
||||
applied({ ...settings, ...patch })
|
||||
await api.patchSettings(patch)
|
||||
// 设置请求串行提交,防止快速切换和片段编辑的旧请求覆盖新值。
|
||||
await saveSettings(patch)
|
||||
await applySettingsAppearance()
|
||||
}
|
||||
|
||||
let saveQueue: Promise<unknown> = Promise.resolve()
|
||||
function saveSettings(patch: Partial<Settings>) {
|
||||
const pending = saveQueue.then(() => api.patchSettings(patch))
|
||||
saveQueue = pending.catch(() => {})
|
||||
return pending
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { connectEvents, type VaultEvent } from './events'
|
||||
import { basename, dirname, joinPath, NOTE_EXT, resolveAssetUrl } from './paths'
|
||||
import { loadSettings, settings } from './settings'
|
||||
import { trackVisualViewport } from './viewport'
|
||||
import { appearancePaused } from '../themes/appearance'
|
||||
|
||||
export type TabStatus = 'loading' | 'ready' | 'conflict' | 'deleted' | 'error'
|
||||
|
||||
@@ -53,7 +54,7 @@ export const ws = reactive({
|
||||
// 快速切换/命令面板的初始输入,null 为关闭;以 > 开头时列出命令
|
||||
switcher: null as string | null,
|
||||
// 打开的设置分组,null 为关闭;index 为从通用入口打开(手机上先显示分组列表,电脑上显示第一组)
|
||||
settings: null as SettingsSection | 'index' | null,
|
||||
settings: (appearancePaused ? 'appearance' : null) as SettingsSection | 'index' | null,
|
||||
// 分享当前笔记的对话框(笔记路径)
|
||||
sharing: null as string | null,
|
||||
// 「移动到…」对话框(笔记或文件夹路径)
|
||||
@@ -798,6 +799,7 @@ export async function startWorkspace() {
|
||||
() => [settings.fontSize, settings.contentWidth, settings.sidebarWidth],
|
||||
() => view?.requestMeasure(),
|
||||
)
|
||||
window.addEventListener('rawmark-appearance', () => requestAnimationFrame(() => view?.requestMeasure()))
|
||||
// 页面隐藏/关闭前尽力发出待保存的修改(部分浏览器关闭标签页时只触发 pagehide)
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') void flushAll(true)
|
||||
|
||||
Reference in new issue
Block a user