Add synced themes, color modes and custom CSS snippets

This commit is contained in:
eezd committed 2026-10-20 10:06:58 +08:00
1 parent 1562519f08
commit 7f65162115
17 files changed
+767 -37

No files matched your search

+1
View File
@@ -95,6 +95,7 @@ func run(addr string) error {
go cleanupSessions(ctx, st)
srv := api.New(v, am, st, hub, idx)
srv.ThemesDir = filepath.Join(dataDir, "themes")
srv.OnSetup = func() { os.Remove(codeFile) }
go srv.FollowMoves(ctx)
httpServer := &http.Server{
+5
View File
@@ -39,6 +39,8 @@ type Server struct {
settingsMu sync.Mutex
// OnSetup 在首次初始化完成后调用(用于删除启动码文件)
OnSetup func()
// ThemesDir 指向数据目录中的主题目录;为空时只有内置主题。
ThemesDir string
}
func New(v *vault.Vault, a *auth.Manager, st *store.Store, hub *events.Hub, index *search.Index) *Server {
@@ -89,6 +91,8 @@ func (s *Server) routes() []route {
{"GET /api/v1/settings", authed, s.getSettings},
{"PATCH /api/v1/settings", sessionOnly, s.patchSettings},
{"GET /api/v1/themes", authed, s.listThemes},
{"GET /api/v1/themes/{id}", authed, s.getTheme},
{"GET /api/v1/shares", sessionOnly, s.listShares},
{"POST /api/v1/shares", sessionOnly, s.createShare},
{"DELETE /api/v1/shares/{token}", sessionOnly, s.deleteShare},
@@ -97,6 +101,7 @@ func (s *Server) routes() []route {
{"DELETE /api/v1/tokens/{id}", sessionOnly, s.deleteToken},
{"GET /api/v1/public/shares/{token}", public, s.publicShare},
{"GET /api/v1/public/shares/{token}/appearance", public, s.publicAppearance},
{"POST /api/v1/public/shares/{token}/unlock", public, s.unlockShare},
{"GET /api/v1/public/shares/{token}/assets/{path...}", public, s.publicShareAsset},
}
+3 -1
View File
@@ -55,7 +55,9 @@ func newFixture(t *testing.T) *fixture {
t.Fatal(err)
}
t.Cleanup(func() { idx.Close() })
srv := httptest.NewServer(New(v, am, st, events.NewHub(), idx).Handler(fstest.MapFS{}))
server := New(v, am, st, events.NewHub(), idx)
server.ThemesDir = filepath.Join(dir, "themes")
srv := httptest.NewServer(server.Handler(fstest.MapFS{}))
t.Cleanup(srv.Close)
f := &fixture{t: t, url: srv.URL, root: v.Root(), store: st, admin: newClient()}
if res := f.do(f.admin, "POST", "/api/v1/auth/login", `{"password":"password123"}`, nil); res.StatusCode != 200 {
+177
View File
@@ -1510,6 +1510,164 @@
}
}
}
},
"/themes": {
"get": {
"summary": "列出主题(含内置默认主题)",
"tags": [
"外观"
],
"responses": {
"200": {
"description": "主题列表",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"themes": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Theme"
}
}
}
}
}
}
}
}
}
},
"/themes/{id}": {
"get": {
"summary": "读取主题 CSS",
"tags": [
"外观"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "主题信息与 CSS",
"content": {
"application/json": {
"schema": {
"allOf": [
{
"$ref": "#/components/schemas/Theme"
},
{
"type": "object",
"properties": {
"css": {
"type": "string"
}
}
}
]
}
}
}
},
"404": {
"description": "主题不存在或文件不完整"
}
}
}
},
"/public/shares/{token}/appearance": {
"get": {
"summary": "读取有效分享使用的外观配置",
"description": "仅返回明暗模式、字号、正文宽度、主题 CSS 和启用的 CSS 片段;密码分享需先解锁,不返回其他设置,不加载插件。",
"tags": [
"分享"
],
"security": [
{}
],
"parameters": [
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "公开的外观配置",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"colorMode": {
"type": "string",
"enum": [
"system",
"light",
"dark"
]
},
"fontSize": {
"type": "integer"
},
"contentWidth": {
"type": "integer"
},
"themeCSS": {
"type": "string"
},
"cssSnippets": {
"type": [
"array",
"null"
],
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"css": {
"type": "string"
},
"enabled": {
"type": "boolean"
}
}
}
},
"theme": {
"type": "string"
}
}
}
}
}
},
"401": {
"description": "需要访问密码"
},
"404": {
"description": "分享不存在、已过期或已撤销"
}
}
}
}
},
"components": {
@@ -1672,6 +1830,25 @@
"description": "Unix 毫秒,0 为从未使用"
}
}
},
"Theme": {
"type": "object",
"required": [
"id",
"name",
"version"
],
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"version": {
"type": "string"
}
}
}
}
}
+165
View File
@@ -0,0 +1,165 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"regexp"
)
var themeIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`)
type themeManifest struct {
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
}
func readThemeFile(root *os.Root, name string, limit int64) ([]byte, error) {
f, err := root.Open(name)
if err != nil {
return nil, err
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("主题文件必须为普通文件")
}
data, err := io.ReadAll(io.LimitReader(f, limit+1))
if err != nil {
return nil, err
}
if int64(len(data)) > limit {
return nil, fmt.Errorf("主题文件过大")
}
return data, nil
}
func (s *Server) readTheme(id string) (themeManifest, string, error) {
var manifest themeManifest
if !themeIDPattern.MatchString(id) || id == "default" || s.ThemesDir == "" {
return manifest, "", fs.ErrNotExist
}
root, err := os.OpenRoot(s.ThemesDir)
if err != nil {
return manifest, "", err
}
defer root.Close()
dir, err := root.OpenRoot(id)
if err != nil {
return manifest, "", err
}
defer dir.Close()
data, err := readThemeFile(dir, "manifest.json", 16<<10)
if err != nil {
return manifest, "", err
}
if err := json.Unmarshal(data, &manifest); err != nil {
return manifest, "", fmt.Errorf("主题 %s 的 manifest.json 无法解析:%w", id, err)
}
if manifest.ID != id || manifest.Name == "" || manifest.Version == "" {
return manifest, "", fmt.Errorf("主题 %s 的 id、name 或 version 无效", id)
}
css, err := readThemeFile(dir, "theme.css", 256<<10)
return manifest, string(css), err
}
func (s *Server) listThemes(w http.ResponseWriter, r *http.Request) {
themes := []themeManifest{{ID: "default", Name: "默认", Version: "1"}}
if s.ThemesDir != "" {
entries, err := os.ReadDir(s.ThemesDir)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
internalError(w, r, err)
return
}
for _, entry := range entries {
if !entry.IsDir() || !themeIDPattern.MatchString(entry.Name()) || entry.Name() == "default" {
continue
}
manifest, _, err := s.readTheme(entry.Name())
if err != nil {
internalError(w, r, err)
return
}
themes = append(themes, manifest)
}
}
writeJSON(w, 200, map[string]any{"themes": themes})
}
func (s *Server) getTheme(w http.ResponseWriter, r *http.Request) {
manifest, css, err := s.readTheme(r.PathValue("id"))
if errors.Is(err, fs.ErrNotExist) {
writeError(w, 404, "主题不存在或文件不完整")
return
}
if err != nil {
internalError(w, r, err)
return
}
writeJSON(w, 200, struct {
themeManifest
CSS string `json:"css"`
}{manifest, css})
}
// 只有有效分享的访客可读取外观;不返回账户、插件和其他应用设置。
func (s *Server) publicAppearance(w http.ResponseWriter, r *http.Request) {
if _, ok := s.openShare(w, r); !ok {
return
}
settings, err := s.readSettings()
if err != nil {
internalError(w, r, err)
return
}
out := map[string]any{}
for _, key := range []string{"colorMode", "fontSize", "contentWidth"} {
if value, ok := settings[key]; ok {
out[key] = value
}
}
var snippets []struct {
ID string `json:"id"`
Name string `json:"name"`
CSS string `json:"css"`
Enabled bool `json:"enabled"`
}
if value, ok := settings["cssSnippets"]; ok {
if err := json.Unmarshal(value, &snippets); err != nil {
internalError(w, r, err)
return
}
}
active := snippets[:0]
for _, snippet := range snippets {
if snippet.Enabled {
active = append(active, snippet)
}
}
out["cssSnippets"] = active
var theme string
if value, ok := settings["theme"]; ok {
if err := json.Unmarshal(value, &theme); err != nil {
internalError(w, r, err)
return
}
}
if theme != "" && theme != "default" {
_, css, err := s.readTheme(theme)
if err != nil {
internalError(w, r, err)
return
}
out["themeCSS"] = css
}
out["theme"] = theme
writeJSON(w, 200, out)
}
+76
View File
@@ -0,0 +1,76 @@
package api
import (
"encoding/json"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
)
func TestThemeAndShareAppearanceBoundaries(t *testing.T) {
f := newFixture(t)
dir := filepath.Join(filepath.Dir(f.root), "themes", "paper")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
for name, data := range map[string]string{
"manifest.json": `{"id":"paper","name":"纸张","version":"1"}`,
"theme.css": `:root { --rm-bg: #faf6ed; }`,
} {
if err := os.WriteFile(filepath.Join(dir, name), []byte(data), 0o644); err != nil {
t.Fatal(err)
}
}
guest := newClient()
if code := f.status(guest, "GET", "/api/v1/themes/paper", "", nil); code != 401 {
t.Fatalf("匿名用户不应读取主题,得到 %d", code)
}
themes := decode[struct{ Themes []themeManifest }](t, f.do(f.admin, "GET", "/api/v1/themes", "", nil))
if len(themes.Themes) != 2 || themes.Themes[1].ID != "paper" {
t.Fatalf("主题列表错误:%+v", themes.Themes)
}
theme := decode[map[string]string](t, f.do(f.admin, "GET", "/api/v1/themes/paper", "", nil))
if !strings.Contains(theme["css"], "#faf6ed") {
t.Fatal("未读取到主题 CSS")
}
f.write("笔记.md", "正文")
f.do(f.admin, "PATCH", "/api/v1/settings", `{"theme":"paper","colorMode":"dark","fontSize":18,"privateSetting":"不公开","cssSnippets":[{"id":"a","name":"启用","css":"body { color: red }","enabled":true},{"id":"b","name":"停用","css":"不公开片段","enabled":false}]}`, nil)
sh := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", `{"path":"笔记.md","password":"访问密码"}`, nil))
base := "/api/v1/public/shares/" + sh.Token
if code := f.status(guest, "GET", base+"/appearance", "", nil); code != 401 {
t.Fatalf("未解锁不能读取外观,得到 %d", code)
}
f.do(guest, "POST", base+"/unlock", `{"password":"访问密码"}`, nil)
res := f.do(guest, "GET", base+"/appearance", "", nil)
raw, err := io.ReadAll(res.Body)
if err != nil {
t.Fatal(err)
}
var appearance map[string]any
if err := json.Unmarshal(raw, &appearance); err != nil {
t.Fatal(err)
}
if res.StatusCode != 200 || appearance["colorMode"] != "dark" || !strings.Contains(appearance["themeCSS"].(string), "#faf6ed") {
t.Fatalf("分享外观错误:%s", raw)
}
if strings.Contains(string(raw), "不公开") || len(appearance["cssSnippets"].([]any)) != 1 {
t.Fatalf("分享泄露了其他设置或停用片段:%s", raw)
}
f.do(f.admin, "DELETE", "/api/v1/shares/"+sh.Token, "", nil)
if code := f.status(guest, "GET", base+"/appearance", "", nil); code != 404 {
t.Fatalf("撤销分享后不能读取外观,得到 %d", code)
}
// CSS 符号链接不能读取主题目录之外的应用数据。
if err := os.Remove(filepath.Join(dir, "theme.css")); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(filepath.Dir(f.root), "rawmark.db"), filepath.Join(dir, "theme.css")); err != nil {
t.Fatal(err)
}
if code := f.status(f.admin, "GET", "/api/v1/themes/paper", "", nil); code != http.StatusInternalServerError {
t.Fatalf("应拒绝越界链接,得到 %d", code)
}
}
+104
View File
@@ -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>
+2
View File
@@ -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) })">
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+3 -1
View File
@@ -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)
+15
View File
@@ -223,6 +223,7 @@ export class MathWidget extends WidgetType {
}
const mermaidSeqs = new WeakMap<HTMLElement, number>()
const mermaidListeners = new WeakMap<HTMLElement, () => void>()
let mermaidSeq = 0
/** Mermaid 图表,editOffset 含义同 MathWidget */
@@ -243,14 +244,28 @@ export class MermaidWidget extends WidgetType {
else editOnMousedown(el, view, this.editOffset)
el.textContent = '图表渲染中…'
this.render(el, view)
const update = () => this.render(el, view)
mermaidListeners.set(el, update)
window.addEventListener('rawmark-appearance', update)
return el
}
// 只复用预览元素:从渲染结果切到编辑时,编辑器会尝试用新组件更新旧元素(旧元素带点击编辑的监听)
updateDOM(dom: HTMLElement, view: EditorView) {
if (this.editOffset !== null || !dom.classList.contains('cm-rm-preview')) return false
this.render(dom, view)
const old = mermaidListeners.get(dom)
if (old) window.removeEventListener('rawmark-appearance', old)
const update = () => this.render(dom, view)
mermaidListeners.set(dom, update)
window.addEventListener('rawmark-appearance', update)
return true
}
destroy(dom: HTMLElement) {
const update = mermaidListeners.get(dom)
if (update) window.removeEventListener('rawmark-appearance', update)
mermaidListeners.delete(dom)
mermaidSeqs.delete(dom)
}
// 渲染是异步的,连续输入时只采用最后一次的结果,并保留旧图直到新图就绪,避免闪烁
private render(el: HTMLElement, view: EditorView) {
const seq = ++mermaidSeq
+14 -8
View File
@@ -16,19 +16,18 @@ export async function renderMath(el: HTMLElement, tex: string, displayMode: bool
let mermaidLoad: Promise<typeof import('mermaid').default> | undefined
let mermaidSeq = 0
function loadMermaid() {
mermaidLoad ??= import('mermaid').then(({ default: mermaid }) => {
const dark = matchMedia('(prefers-color-scheme: dark)').matches
// strict:图表中的 HTML 会被清理,点击事件等交互被禁用
const config: MermaidConfig = { startOnLoad: false, securityLevel: 'strict', theme: dark ? 'dark' : 'default' }
mermaid.initialize(config)
return mermaid
})
mermaidLoad ??= import('mermaid').then(({ default: mermaid }) => mermaid)
return mermaidLoad
}
/** 渲染 Mermaid 图表,返回 SVG 标记;语法错误时抛出带说明的 Error */
export async function renderMermaid(code: string): Promise<string> {
async function renderDiagram(code: string): Promise<string> {
const mermaid = await loadMermaid()
const mode = document.documentElement.dataset.rmColorMode
const dark = mode === 'dark' || (mode === undefined && matchMedia('(prefers-color-scheme: dark)').matches)
// 配置与渲染串行进行,避免切换模式时并发图表读到另一个模式的全局配置。
const config: MermaidConfig = { startOnLoad: false, securityLevel: 'strict', theme: dark ? 'dark' : 'default' }
mermaid.initialize(config)
const id = `rm-mermaid-${++mermaidSeq}`
try {
return (await mermaid.render(id, code)).svg
@@ -39,6 +38,13 @@ export async function renderMermaid(code: string): Promise<string> {
}
}
let renderQueue: Promise<unknown> = Promise.resolve()
export function renderMermaid(code: string): Promise<string> {
const result = renderQueue.then(() => renderDiagram(code))
renderQueue = result.catch(() => {})
return result
}
let emojiLoad: Promise<Map<string, string>> | undefined
/** 按 GitHub/Typora 的名称查找 emoji,如 smile → 😄;未知名称返回 undefined */
+27 -2
View File
@@ -2,6 +2,7 @@
import { requestTokens } from '../highlight/client'
import { renderMarkdown } from '../markdown/html'
import { hydrate, renderMermaid } from '../markdown/render'
import { applyAppearance, sanitizeAppearance } from '../themes/appearance'
import '../themes/base.css'
import './share.css'
@@ -22,12 +23,28 @@ function message(text: string) {
async function load() {
let res: Response
try {
res = await fetch(api, { credentials: 'same-origin' })
res = await fetch(api, { credentials: 'same-origin', cache: 'no-store' })
} catch {
message('网络错误,请检查连接后刷新')
return
}
if (res.ok) render((await res.json()) as SharedNote)
if (res.ok) {
const note = (await res.json()) as SharedNote
let appearanceError = false
try {
const response = await fetch(api + '/appearance', { cache: 'no-store' })
if (!response.ok) throw new Error('读取外观失败')
const appearance = await response.json() as Record<string, unknown>
const rootStyle = document.documentElement.style
if (typeof appearance.fontSize === 'number') rootStyle.setProperty('--rm-font-size', appearance.fontSize + 'px')
if (typeof appearance.contentWidth === 'number') rootStyle.setProperty('--rm-content-width', appearance.contentWidth ? appearance.contentWidth + 'px' : 'none')
applyAppearance(sanitizeAppearance(appearance), typeof appearance.themeCSS === 'string' ? appearance.themeCSS : '')
} catch {
appearanceError = true
}
render(note)
if (appearanceError) root.prepend(Object.assign(document.createElement('p'), { className: 'rm-share-error', textContent: '外观加载失败,可刷新重试' }))
}
else if (res.status === 401) askPassword()
else if (res.status === 404) message('分享不存在、已过期或已被撤销')
else message(`加载失败(${res.status})`)
@@ -127,6 +144,7 @@ async function decorateCode(code: HTMLElement) {
if (lang === 'mermaid') {
const figure = document.createElement('div')
figure.className = 'rm-share-mermaid'
figure.dataset.code = text
try {
figure.innerHTML = await renderMermaid(text)
code.parentElement!.replaceWith(figure)
@@ -157,4 +175,11 @@ async function decorateCode(code: HTMLElement) {
code.replaceChildren(out)
}
window.addEventListener('rawmark-appearance', () => {
for (const el of root.querySelectorAll<HTMLElement>('.rm-share-mermaid')) {
const code = el.dataset.code
if (code !== undefined) void renderMermaid(code).then((svg) => { el.innerHTML = svg }).catch(() => { el.textContent = '图表渲染失败,请刷新重试' })
}
})
void load()
+75
View File
@@ -0,0 +1,75 @@
export interface CssSnippet {
id: string
name: string
css: string
enabled: boolean
}
export interface Appearance {
colorMode: 'system' | 'light' | 'dark'
theme: string
cssSnippets: CssSnippet[]
}
export const appearancePaused = new URLSearchParams(location.search).has('reset-appearance')
export function sanitizeAppearance(raw: Record<string, unknown>): Appearance {
return {
colorMode: raw.colorMode === 'light' || raw.colorMode === 'dark' ? raw.colorMode : 'system',
theme: typeof raw.theme === 'string' && /^[a-z0-9][a-z0-9_-]{0,63}$/.test(raw.theme) ? raw.theme : 'default',
cssSnippets: Array.isArray(raw.cssSnippets) ? raw.cssSnippets.filter((s): s is CssSnippet =>
s !== null && typeof s === 'object' && typeof s.id === 'string' && typeof s.name === 'string' &&
typeof s.css === 'string' && typeof s.enabled === 'boolean',
).map((s) => ({ id: s.id, name: s.name, css: s.css, enabled: s.enabled })) : [],
}
}
let current: Appearance = { colorMode: 'system', theme: 'default', cssSnippets: [] }
let themeCSS = ''
const darkMode = matchMedia('(prefers-color-scheme: dark)')
let rendered = ''
function style(id: string, css: string) {
let el = document.getElementById(id) as HTMLStyleElement | null
if (!el) {
el = document.createElement('style')
el.id = id
document.head.append(el)
}
if (el.textContent !== css) el.textContent = css
}
function render() {
const mode = appearancePaused ? 'system' : current.colorMode
const dark = mode === 'system' ? darkMode.matches : mode === 'dark'
const signature = JSON.stringify([dark, appearancePaused ? '' : [current.theme, themeCSS, current.cssSnippets]])
if (signature === rendered) return
rendered = signature
const root = document.documentElement
root.dataset.rmColorMode = dark ? 'dark' : 'light'
root.dataset.rmTheme = appearancePaused ? 'default' : current.theme
style('rm-theme', appearancePaused ? '' : themeCSS)
// 独立样式元素避免某个片段的未闭合语法吞掉其他片段。
document.querySelectorAll('style[data-rm-snippet]').forEach((el) => el.remove())
if (!appearancePaused) for (const snippet of current.cssSnippets) {
if (!snippet.enabled) continue
const el = document.createElement('style')
el.dataset.rmSnippet = snippet.id
el.textContent = snippet.css
document.head.append(el)
}
const bg = getComputedStyle(root).getPropertyValue('--rm-bg').trim()
for (const meta of document.querySelectorAll<HTMLMetaElement>('meta[name="theme-color"]')) {
meta.removeAttribute('media')
meta.content = bg
}
window.dispatchEvent(new Event('rawmark-appearance'))
}
darkMode.addEventListener('change', render)
export function applyAppearance(next: Appearance, css: string) {
current = next
themeCSS = css
render()
}
+41 -2
View File
@@ -3,6 +3,16 @@
* 代码块与行内代码配色取自 vuejs.org(@vue/theme + Shiki github-dark)。
*/
:root {
color-scheme: light;
--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);
--rm-font-text: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', 'Noto Sans CJK SC', 'Helvetica Neue', sans-serif;
--rm-font-mono: ui-monospace, 'SF Mono', 'SFMono-Regular', Menlo, Monaco, 'Cascadia Mono', Consolas,
@@ -27,8 +37,8 @@
--rm-highlight-bg: rgba(255, 213, 0, 0.35);
}
@media (prefers-color-scheme: dark) {
:root {
:root:where([data-rm-color-mode="dark"]) {
color-scheme: dark;
--rm-bg: #1a1a1a;
--rm-text: rgba(255, 255, 255, 0.87);
--rm-text-muted: rgba(235, 235, 235, 0.6);
@@ -39,6 +49,35 @@
--rm-inline-code-text: #aac8e4;
--rm-code-bg: #242424;
--rm-highlight-bg: rgba(255, 213, 0, 0.28);
--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);
}
@media (prefers-color-scheme: dark) {
:root:where(:not([data-rm-color-mode])) {
color-scheme: dark;
--rm-bg: #1a1a1a;
--rm-text: rgba(255, 255, 255, 0.87);
--rm-text-muted: rgba(235, 235, 235, 0.6);
--rm-divider: rgba(84, 84, 84, 0.48);
--rm-accent: #42d392;
--rm-accent-strong: #42b883;
--rm-inline-code-bg: #2f2f2f;
--rm-inline-code-text: #aac8e4;
--rm-code-bg: #242424;
--rm-highlight-bg: rgba(255, 213, 0, 0.28);
--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);
}
}