feat(settings): manage shared interface and editor fonts

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

No files matched your search

+1 -1
View File
@@ -33,7 +33,7 @@ docker run -d --name rawmark \
- 首次打开直接设置登录密码,之后凭密码登录;请在将新实例开放给他人访问前完成初始化。
- 笔记以普通 `.md` 文件保存在 `数据目录/vault/`,粘贴、上传和导入的图片统一存放在笔记同级的 `assets/`,链接使用 `./assets/文件名`,同目录相同内容去重。笔记跨目录移动时图片跟随,仍被其他笔记引用的原图保留;删除单篇笔记不会删除共享图片。旧 `笔记名.assets/` 仍可读取、移动与恢复;外部修改会实时同步到网页。
- 知识工具:标准 Markdown 链接构成图谱,支持正文 `#标签` 与 YAML `tags`。笔记或文件夹在应用内改名、移动时维护入链和相对出链;公式和代码示例不参与关系提取。
- 外观:浅色玻璃工作台与深色模式,默认字体为 Maple Mono Normal NL NF CN v7.9(界面、正文与代码),字体资源随应用本地提供,文字在所需字体加载完成后显示,加载失败会提示刷新;代码块使用 highlight.js,固定采用官网的 Atom One Dark 配色,与页面明暗模式独立;界面字号默认 14px,可输入 14–20px 并单独重置,与编辑器及阅读字号独立;排版、玻璃透明度、选中文本工具栏均可在设置中调整并即时保存。
- 外观:浅色玻璃工作台与深色模式,默认字体为 Maple Mono Normal NL NF CN v7.9(界面、正文与代码),字体资源随应用本地提供,文字在所需字体加载完成后显示,加载失败会提示刷新;代码块使用 highlight.js,固定采用官网的 Atom One Dark 配色,与页面明暗模式独立;界面、正文与代码字体可分别选择,各字体下拉均支持导入 TTF/OTF/WOFF/WOFF2(单个不超过30 MB)及删除;Maple为不可删除的默认字体,删除正在使用的字体会恢复Maple,导入字体保存在服务器数据目录的fonts/并跨设备共用。界面字号默认 14px,可输入 14–20px 并单独重置,与编辑器及阅读字号独立;排版、玻璃透明度、选中文本工具栏均可在设置中调整并即时保存。
- 选区格式:电脑与触屏选中文字后提供外观、加粗、斜体、下划线、删除线、高亮和链接。外观可设置配色、字体、局部字号与效果;下划线及外观以受限的标准 HTML 行内标签保存在 Markdown 中,源码与导出保留标签,其他软件的显示取决于其 HTML 支持。清除样式只重置局部外观;底部不再显示格式栏。
- 图片管理:侧栏默认隐藏 `assets` 和 `笔记名.assets`;右键文件夹选择「图片管理」,查看当前目录及直属附件目录的图片,支持检索、排序、网格/列表、批量上传、引用查看及预览、重命名、移动、替换。可切换整个知识库;清理孤立图片须确认候选,服务端再次核对全库引用后移入回收站,可恢复。上传至所选目录的 `assets/`,相同内容自动去重。根目录可在侧栏空白处右键或通过命令面板打开。图片改名/移动会更新笔记中的本地引用;替换保留路径,选择相同扩展名的图片(最大 50 MB),旧图片不进回收站。
- `--user` 让容器以宿主机用户身份读写数据目录;不指定时容器以 UID 65532 运行,需要该用户对目录有写权限。
+1
View File
@@ -83,6 +83,7 @@ func run(addr string) error {
srv := api.New(v, am, st, hub, idx)
srv.ThemesDir = filepath.Join(dataDir, "themes")
srv.PluginsDir = filepath.Join(dataDir, "plugins")
srv.FontsDir = filepath.Join(dataDir, "fonts")
httpServer := &http.Server{
Addr: addr,
Handler: srv.Handler(web.Dist()),
+5
View File
@@ -40,6 +40,7 @@ type Server struct {
// ThemesDir 指向数据目录中的主题目录;为空时只有内置主题。
ThemesDir string
PluginsDir string
FontsDir string
pluginsMu sync.Mutex
}
@@ -98,6 +99,10 @@ func (s *Server) routes() []route {
{"GET /api/v1/settings", authed, s.getSettings},
{"PATCH /api/v1/settings", sessionOnly, s.patchSettings},
{"GET /api/v1/fonts", sessionOnly, s.listFonts},
{"POST /api/v1/fonts", sessionOnly, s.importFont},
{"GET /api/v1/fonts/{id}/file", sessionOnly, s.fontFile},
{"DELETE /api/v1/fonts/{id}", sessionOnly, s.deleteFont},
{"GET /api/v1/plugins", sessionOnly, s.listPlugins},
{"POST /api/v1/plugins", sessionOnly, s.installPlugin},
{"PATCH /api/v1/plugins/{id}", sessionOnly, s.togglePlugin},
+1
View File
@@ -130,6 +130,7 @@ func newFixture(t *testing.T) *fixture {
server := New(v, am, st, hub, idx)
server.ThemesDir = filepath.Join(dir, "themes")
server.PluginsDir = filepath.Join(dir, "plugins")
server.FontsDir = filepath.Join(dir, "fonts")
srv := httptest.NewServer(server.Handler(fstest.MapFS{}))
t.Cleanup(srv.Close)
f := &fixture{t: t, url: srv.URL, root: v.Root(), store: st, hub: hub, admin: newClient()}
+254
View File
@@ -0,0 +1,254 @@
package api
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"rawmark/internal/events"
)
const maxFontBytes = 30 << 20
var fontIDPattern = regexp.MustCompile(`^custom-[a-f0-9]{64}$`)
type fontInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Format string `json:"format"`
}
func (s *Server) openFont(id string) (*os.Root, fontInfo, error) {
var info fontInfo
if !fontIDPattern.MatchString(id) || s.FontsDir == "" {
return nil, info, fs.ErrNotExist
}
root, err := os.OpenRoot(s.FontsDir)
if err != nil {
return nil, info, err
}
defer root.Close()
dir, err := root.OpenRoot(id)
if err != nil {
return nil, info, err
}
data, err := dir.ReadFile("manifest.json")
if err == nil {
err = json.Unmarshal(data, &info)
}
if err == nil && (info.ID != id || info.Name == "" || !validFontFormat(info.Format)) {
err = fmt.Errorf("字体安装信息无效")
}
if err != nil {
dir.Close()
return nil, info, err
}
return dir, info, nil
}
func validFontFormat(format string) bool {
return format == "ttf" || format == "otf" || format == "woff" || format == "woff2"
}
func (s *Server) listFonts(w http.ResponseWriter, r *http.Request) {
s.settingsMu.Lock()
defer s.settingsMu.Unlock()
fonts := []fontInfo{}
if s.FontsDir != "" {
entries, err := os.ReadDir(s.FontsDir)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
internalError(w, r, err)
return
}
for _, entry := range entries {
if !entry.IsDir() || !fontIDPattern.MatchString(entry.Name()) {
continue
}
dir, info, err := s.openFont(entry.Name())
if err != nil {
internalError(w, r, err)
return
}
dir.Close()
fonts = append(fonts, info)
}
}
writeJSON(w, 200, map[string]any{"fonts": fonts})
}
func (s *Server) importFont(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxFontBytes+(1<<20))
if err := r.ParseMultipartForm(1 << 20); err != nil {
writeError(w, 400, "无法读取字体文件,单个文件上限 30 MB")
return
}
defer r.MultipartForm.RemoveAll()
f, header, err := r.FormFile("file")
if err != nil {
writeError(w, 400, "请选择字体文件")
return
}
defer f.Close()
format := strings.TrimPrefix(strings.ToLower(filepath.Ext(header.Filename)), ".")
data, err := io.ReadAll(io.LimitReader(f, maxFontBytes+1))
if err != nil {
internalError(w, r, err)
return
}
if len(data) > maxFontBytes {
writeError(w, 413, "字体文件超过 30 MB")
return
}
magic := map[string]string{"ttf": "\x00\x01\x00\x00", "otf": "OTTO", "woff": "wOFF", "woff2": "wOF2"}
if !validFontFormat(format) || len(data) < 12 || string(data[:4]) != magic[format] {
writeError(w, 400, "请导入有效的 TTF、OTF、WOFF 或 WOFF2 字体文件")
return
}
if s.FontsDir == "" {
writeError(w, 503, "字体目录未配置")
return
}
hash := sha256.Sum256(data)
id := "custom-" + hex.EncodeToString(hash[:])
info := fontInfo{id, strings.TrimSuffix(filepath.Base(header.Filename), filepath.Ext(header.Filename)), format}
if strings.TrimSpace(info.Name) == "" || len(info.Name) > 200 || strings.ContainsAny(info.Name, "\x00\r\n") {
writeError(w, 400, "字体文件名无效或过长")
return
}
s.settingsMu.Lock()
defer s.settingsMu.Unlock()
if dir, existing, err := s.openFont(id); err == nil {
dir.Close()
writeJSON(w, 200, existing)
return
} else if !errors.Is(err, fs.ErrNotExist) {
internalError(w, r, err)
return
}
if err := os.MkdirAll(s.FontsDir, 0o755); err != nil {
internalError(w, r, err)
return
}
stage, err := os.MkdirTemp(s.FontsDir, ".import-")
if err != nil {
internalError(w, r, err)
return
}
defer os.RemoveAll(stage)
manifest, err := json.Marshal(info)
if err == nil {
err = os.WriteFile(filepath.Join(stage, "manifest.json"), manifest, 0o600)
}
if err == nil {
err = os.WriteFile(filepath.Join(stage, "font."+format), data, 0o600)
}
if err == nil {
err = os.Rename(stage, filepath.Join(s.FontsDir, id))
}
if err != nil {
internalError(w, r, err)
return
}
s.hub.Broadcast(events.Event{Type: "settings"})
writeJSON(w, 201, info)
}
func (s *Server) fontFile(w http.ResponseWriter, r *http.Request) {
s.settingsMu.Lock()
dir, info, err := s.openFont(r.PathValue("id"))
if errors.Is(err, fs.ErrNotExist) {
s.settingsMu.Unlock()
writeError(w, 404, "字体不存在")
return
}
if err != nil {
s.settingsMu.Unlock()
internalError(w, r, err)
return
}
defer dir.Close()
f, err := dir.Open("font." + info.Format)
s.settingsMu.Unlock()
if err != nil {
internalError(w, r, err)
return
}
defer f.Close()
stat, err := f.Stat()
if err != nil || !stat.Mode().IsRegular() {
if err == nil {
err = fmt.Errorf("字体必须为普通文件")
}
internalError(w, r, err)
return
}
w.Header().Set("Content-Type", "font/"+info.Format)
http.ServeContent(w, r, "font."+info.Format, stat.ModTime(), f)
}
func (s *Server) deleteFont(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "maple" {
writeError(w, 400, "Maple 是默认字体,不可删除")
return
}
s.settingsMu.Lock()
defer s.settingsMu.Unlock()
dir, _, err := s.openFont(id)
if errors.Is(err, fs.ErrNotExist) {
writeError(w, 404, "字体不存在")
return
}
if err != nil {
internalError(w, r, err)
return
}
dir.Close()
settings, err := s.readSettings()
if err != nil {
internalError(w, r, err)
return
}
for _, key := range []string{"uiFont", "textFont", "codeFont"} {
var selected string
if json.Unmarshal(settings[key], &selected) == nil && selected == id {
settings[key] = json.RawMessage(`"maple"`)
}
}
data, err := json.Marshal(settings)
if err != nil {
internalError(w, r, err)
return
}
root, err := os.OpenRoot(s.FontsDir)
if err != nil {
internalError(w, r, err)
return
}
defer root.Close()
// 先撤下资源;设置写失败时恢复,避免留下指向已删除字体的设置。
removed := ".remove-" + id
if err := root.Rename(id, removed); err != nil {
internalError(w, r, err)
return
}
if err := s.store.SetSetting(settingsKey, string(data)); err != nil {
internalError(w, r, errors.Join(err, root.Rename(removed, id)))
return
}
s.hub.Broadcast(events.Event{Type: "settings"})
if err := root.RemoveAll(removed); err != nil {
internalError(w, r, fmt.Errorf("字体已停用,但文件清理失败:%w", err))
return
}
writeJSON(w, 200, map[string]bool{"ok": true})
}
+173
View File
@@ -0,0 +1,173 @@
package api
import (
"bytes"
"database/sql"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
)
func uploadTestFont(t *testing.T, f *fixture, client *http.Client, name string, data []byte) *http.Response {
t.Helper()
var body bytes.Buffer
form := multipart.NewWriter(&body)
file, err := form.CreateFormFile("file", name)
if err != nil {
t.Fatal(err)
}
if _, err := file.Write(data); err != nil {
t.Fatal(err)
}
if err := form.Close(); err != nil {
t.Fatal(err)
}
req, err := http.NewRequest("POST", f.url+"/api/v1/fonts", &body)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", form.FormDataContentType())
res, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
return res
}
func testFontData(t *testing.T) []byte {
t.Helper()
data, err := os.ReadFile("../../web/src/themes/fonts/maple/regular-00.woff2")
if err != nil {
t.Fatal(err)
}
return data
}
func TestFontImportReuseReadAndDelete(t *testing.T) {
f := newFixture(t)
data := testFontData(t)
info := decode[fontInfo](t, uploadTestFont(t, f, f.admin, "示例字体.woff2", data))
if !fontIDPattern.MatchString(info.ID) || info.Name != "示例字体" || info.Format != "woff2" {
t.Fatalf("字体信息错误:%+v", info)
}
again := decode[fontInfo](t, uploadTestFont(t, f, f.admin, "renamed.woff2", data))
if again != info {
t.Fatalf("相同内容未复用原字体:%+v", again)
}
list := decode[struct{ Fonts []fontInfo }](t, f.do(f.admin, "GET", "/api/v1/fonts", "", nil))
if len(list.Fonts) != 1 || list.Fonts[0] != info {
t.Fatalf("列表不符:%+v", list)
}
res := f.do(f.admin, "GET", "/api/v1/fonts/"+info.ID+"/file", "", nil)
got, err := io.ReadAll(res.Body)
res.Body.Close()
if err != nil || !bytes.Equal(got, data) || res.Header.Get("Content-Type") != "font/woff2" {
t.Fatal("字体文件不一致", err)
}
f.do(f.admin, "PATCH", "/api/v1/settings", `{"uiFont":"`+info.ID+`","textFont":"`+info.ID+`","codeFont":"`+info.ID+`","plugin.data":{"keep":true}}`, nil).Body.Close()
f.do(f.admin, "DELETE", "/api/v1/fonts/"+info.ID, "", nil).Body.Close()
settings := decode[map[string]any](t, f.do(f.admin, "GET", "/api/v1/settings", "", nil))
for _, key := range []string{"uiFont", "textFont", "codeFont"} {
if settings[key] != "maple" {
t.Fatalf("%s 未恢复默认:%v", key, settings[key])
}
}
if settings["plugin.data"] == nil {
t.Fatal("无关设置被删除")
}
if code := f.status(f.admin, "GET", "/api/v1/fonts/"+info.ID+"/file", "", nil); code != 404 {
t.Fatal("已删除字体仍可读取", code)
}
list = decode[struct{ Fonts []fontInfo }](t, f.do(f.admin, "GET", "/api/v1/fonts", "", nil))
if len(list.Fonts) != 0 {
t.Fatal("已删除字体仍在列表")
}
}
func TestFontBoundaries(t *testing.T) {
f := newFixture(t)
guest := newClient()
for _, endpoint := range []string{"/api/v1/fonts", "/api/v1/fonts/maple/file"} {
if code := f.status(guest, "GET", endpoint, "", nil); code != 401 {
t.Fatal("匿名字体访问未拒绝", code)
}
}
res := uploadTestFont(t, f, guest, "font.woff2", testFontData(t))
res.Body.Close()
if res.StatusCode != 401 {
t.Fatal("匿名上传未拒绝", res.StatusCode)
}
if code := f.status(f.admin, "DELETE", "/api/v1/fonts/maple", "", nil); code != 400 {
t.Fatal("Maple不应可删除", code)
}
for _, tc := range []struct {
name string
data []byte
}{
{"font.exe", testFontData(t)}, {"font.ttf", testFontData(t)}, {".woff2", testFontData(t)}, {"font.woff2", []byte("invalid")},
} {
res := uploadTestFont(t, f, f.admin, tc.name, tc.data)
res.Body.Close()
if res.StatusCode != 400 {
t.Fatalf("非法字体 %s 未拒绝:%d", tc.name, res.StatusCode)
}
}
res = uploadTestFont(t, f, f.admin, "large.woff2", bytes.Repeat([]byte{'x'}, maxFontBytes+1))
res.Body.Close()
if res.StatusCode != http.StatusRequestEntityTooLarge {
t.Fatal("超大字体未拒绝", res.StatusCode)
}
token := decode[struct{ Token string }](t, f.do(f.admin, "POST", "/api/v1/tokens", `{"name":"fonts","scope":"write"}`, nil))
for _, tc := range []struct{ method, path string }{{"GET", "/api/v1/fonts"}, {"DELETE", "/api/v1/fonts/maple"}} {
if code := f.status(guest, tc.method, tc.path, "", map[string]string{"Authorization": "Bearer " + token.Token}); code != 403 {
t.Fatal("令牌不应管理字体", code)
}
}
info := decode[fontInfo](t, uploadTestFont(t, f, f.admin, "font.woff2", testFontData(t)))
dir := filepath.Join(filepath.Dir(f.root), "fonts", info.ID)
if err := os.Remove(filepath.Join(dir, "font.woff2")); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(filepath.Dir(f.root), "rawmark.db"), filepath.Join(dir, "font.woff2")); err != nil {
t.Fatal(err)
}
if code := f.status(f.admin, "GET", "/api/v1/fonts/"+info.ID+"/file", "", nil); code != 500 {
t.Fatal("字体不应读取目录外的数据", code)
}
}
func TestFontDeleteRollsBackWhenSettingsWriteFails(t *testing.T) {
f := newFixture(t)
info := decode[fontInfo](t, uploadTestFont(t, f, f.admin, "font.woff2", testFontData(t)))
f.do(f.admin, "PATCH", "/api/v1/settings", `{"uiFont":"`+info.ID+`"}`, nil).Body.Close()
db, err := sql.Open("sqlite", filepath.Join(filepath.Dir(f.root), "rawmark.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(`CREATE TRIGGER reject_font_settings BEFORE UPDATE ON settings BEGIN SELECT RAISE(ABORT, 'test write failure'); END`); err != nil {
t.Fatal(err)
}
if code := f.status(f.admin, "DELETE", "/api/v1/fonts/"+info.ID, "", nil); code != 500 {
t.Fatal("设置失败应报告错误", code)
}
res := f.do(f.admin, "GET", "/api/v1/fonts/"+info.ID+"/file", "", nil)
res.Body.Close()
settings := decode[map[string]any](t, f.do(f.admin, "GET", "/api/v1/settings", "", nil))
if settings["uiFont"] != info.ID {
t.Fatal("失败后设置未保留")
}
entries, err := os.ReadDir(filepath.Join(filepath.Dir(f.root), "fonts"))
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
if strings.HasPrefix(e.Name(), ".remove-") {
t.Fatal("失败后字体未恢复")
}
}
}
+133
View File
@@ -2022,6 +2022,139 @@
}
}
}
},
"/fonts": {
"get": {
"summary": "列出已导入字体",
"tags": [
"设置"
],
"security": [
{
"cookie": []
}
],
"responses": {
"200": {
"description": "成功"
},
"401": {
"description": "未登录"
},
"403": {
"description": "只接受登录会话"
}
}
},
"post": {
"summary": "导入字体,按内容去重(TTF/OTF/WOFF/WOFF2,上限30 MB)",
"tags": [
"设置"
],
"security": [
{
"cookie": []
}
],
"responses": {
"201": {
"description": "成功"
},
"401": {
"description": "未登录"
},
"403": {
"description": "只接受登录会话"
}
},
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"required": [
"file"
],
"properties": {
"file": {
"type": "string",
"format": "binary"
}
}
}
}
}
}
}
},
"/fonts/{id}": {
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"delete": {
"summary": "删除导入字体,将使用它的界面、正文和代码字体恢复Maple;Maple不可删除",
"tags": [
"设置"
],
"security": [
{
"cookie": []
}
],
"responses": {
"200": {
"description": "成功"
},
"401": {
"description": "未登录"
},
"403": {
"description": "只接受登录会话"
}
}
}
},
"/fonts/{id}/file": {
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"get": {
"summary": "读取导入字体文件",
"tags": [
"设置"
],
"security": [
{
"cookie": []
}
],
"responses": {
"200": {
"description": "成功"
},
"401": {
"description": "未登录"
},
"403": {
"description": "只接受登录会话"
}
}
}
}
},
"components": {
+113
View File
@@ -0,0 +1,113 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { api } from './api'
import { customFonts, loadFonts } from './fonts'
import { FONT_PRESETS } from '../themes/typography'
import { loadSettings, settings, SettingsAppearanceError, updateSettings, type Settings } from './settings'
import { toast } from './workspace'
import Icon from './Icon.vue'
const props = defineProps<{ id: string; setting: 'uiFont' | 'textFont' | 'codeFont' }>()
const expanded = ref(false)
const busy = ref(false)
const error = ref('')
const fileInput = ref<HTMLInputElement>()
const fonts = computed(() => [
...Object.entries(FONT_PRESETS).filter(([id]) => !settings.hiddenFonts.includes(id)).map(([id, font]) => ({ id, name: font.label })),
...customFonts.value,
])
const selected = computed(() => fonts.value.find(font => font.id === settings[props.setting]) ?? { id: 'maple', name: FONT_PRESETS.maple.label })
async function choose(id: string) {
error.value = ''
busy.value = true
try { if (await save({ [props.setting]: id })) expanded.value = false }
finally { busy.value = false }
}
async function save(patch: Partial<Settings>) {
try { await updateSettings(patch); return true }
catch (cause) {
if (cause instanceof SettingsAppearanceError) { toast(cause.message); return true }
error.value = cause instanceof Error ? cause.message : String(cause)
try { await loadSettings() }
catch (reload) { error.value += ';重新读取设置失败:' + String(reload) }
return false
}
}
async function importFont(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
busy.value = true
error.value = ''
try {
if (file.size > 30 * 1024 * 1024) throw new Error('字体文件超过 30 MB')
if (!/\.(ttf|otf|woff2?)$/i.test(file.name)) throw new Error('请选择 TTF、OTF、WOFF 或 WOFF2 字体')
// 让浏览器校验实际字体;无效字体不会上传或进入页面的字体加载集合。
const data = await file.arrayBuffer()
try { await new FontFace('rawmark-import-check', data).load() }
catch (cause) { throw new Error('无法解析字体文件,请选择有效的字体。', { cause }) }
const font = await api.importFont(file)
await loadFonts()
if (!await save({ [props.setting]: font.id })) return
expanded.value = false
toast('字体已导入')
} catch (cause) { error.value = cause instanceof Error ? cause.message : String(cause) }
finally { busy.value = false }
}
async function remove(id: string) {
busy.value = true
error.value = ''
try {
if (Object.hasOwn(FONT_PRESETS, id)) {
const patch: Partial<typeof settings> = { hiddenFonts: [...settings.hiddenFonts, id] }
for (const key of ['uiFont', 'textFont', 'codeFont'] as const) if (settings[key] === id) patch[key] = 'maple'
if (!await save(patch)) return
} else {
await api.deleteFont(id)
await loadSettings()
}
toast('字体已删除,使用它的位置已恢复 Maple')
} catch (cause) { error.value = cause instanceof Error ? cause.message : String(cause) }
finally { busy.value = false }
}
function onKey(event: KeyboardEvent) {
if (event.key === 'Escape' && expanded.value) { event.stopPropagation(); expanded.value = false }
}
</script>
<template>
<div class="rm-font-picker" @keydown="onKey">
<button :id="id" class="rm-btn rm-font-trigger" :aria-expanded="expanded" :aria-controls="id + '-options'" :disabled="busy" @click="expanded = !expanded"><span>{{ selected.name }}</span><Icon name="chevron" /></button>
<div v-if="expanded" :id="id + '-options'" class="rm-font-options">
<div v-for="font in fonts" :key="font.id" class="rm-font-option">
<button class="rm-font-choice" :class="{ on: font.id === settings[setting] }" :aria-pressed="font.id === settings[setting]" :disabled="busy" @click="choose(font.id)"><span>{{ font.name }}</span><small v-if="font.id === 'maple'">默认</small></button>
<button v-if="font.id !== 'maple'" class="rm-icon-btn" :aria-label="'删除字体 ' + font.name" title="删除字体" :disabled="busy" @click="remove(font.id)"><Icon name="trash" /></button>
</div>
<button class="rm-btn rm-font-import" :disabled="busy" @click="fileInput!.click()"><Icon name="import" />{{ busy ? '处理中…' : '导入字体' }}</button>
<p class="rm-share-note">支持 TTF、OTF、WOFF、WOFF2,单个文件不超过 30 MB。</p>
</div>
<input ref="fileInput" type="file" accept=".ttf,.otf,.woff,.woff2" hidden :aria-label="'导入字体 ' + id" @change="importFont" />
<p v-if="error" class="rm-auth-error" role="alert">{{ error }}</p>
</div>
</template>
<style scoped>
.rm-font-picker { position: relative; width: 260px; max-width: 100%; min-width: 0; }
.rm-font-trigger { width: 100%; justify-content: space-between; text-align: left; }
.rm-font-trigger span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rm-font-options { position: absolute; top: calc(100% + 4px); right: 0; z-index: 10; width: 100%; box-sizing: border-box; border: 1px solid var(--rm-divider); border-radius: 8px; padding: var(--rm-space-1); max-height: min(320px, 45dvh); overflow: auto; background: var(--rm-bg); box-shadow: var(--rm-shadow); }
.rm-font-option { display: flex; align-items: center; gap: var(--rm-space-1); }
.rm-font-choice { display: flex; align-items: center; flex: 1; min-width: 0; padding: 6px 8px; text-align: left; overflow-wrap: anywhere; min-height: var(--rm-control-size); border: 0; border-radius: 4px; background: transparent; color: inherit; font: inherit; cursor: pointer; }
.rm-font-choice span { flex: 1; min-width: 0; }
.rm-font-choice:hover, .rm-font-choice.on { background: var(--rm-hover); }
.rm-font-choice small { flex: none; margin-left: 8px; color: var(--rm-text-muted); white-space: nowrap; }
.rm-font-import { width: 100%; margin-top: var(--rm-space-1); }
.rm-font-options p { margin: var(--rm-space-2); }
@media (pointer: coarse) { .rm-font-choice { min-height: 44px; } }
</style>
+5 -3
View File
@@ -4,7 +4,8 @@ import { api, ApiError } from './api'
import { runCommand } from './commands'
import Icon from './Icon.vue'
import AppearanceSettings from './AppearanceSettings.vue'
import { CODE_FONTS, TEXT_FONTS, UI_FONT_SIZE } from '../themes/typography'
import { UI_FONT_SIZE } from '../themes/typography'
import FontPicker from './FontPicker.vue'
import PluginsPanel from './PluginsPanel.vue'
import { installApp, installPrompt } from './pwa'
import { CONTENT_WIDTHS, DEFAULTS, TAB_SIZES, loadSettings, settings, settingsSave, SettingsAppearanceError, updateSettings, type Settings } from './settings'
@@ -164,6 +165,7 @@ async function changePassword() {
<div v-show="!searching" class="rm-settings-page">
<PluginsPanel v-if="section === 'plugins'" />
<template v-else-if="section === 'appearance'">
<SettingRow id="ui-font" control="rm-ui-font" resettable @reset="change({ uiFont: DEFAULTS.uiFont })"><FontPicker id="rm-ui-font" setting="uiFont" /></SettingRow>
<SettingRow id="ui-font-size" control="rm-ui-font-size" resettable @reset="change({ uiFontSize: DEFAULTS.uiFontSize })">
<label class="rm-font-input"><input id="rm-ui-font-size" type="number" :min="UI_FONT_SIZE.min" :max="UI_FONT_SIZE.max" step="1" :value="settings.uiFontSize" @blur="changeUIFont" @keydown.enter="($event.target as HTMLInputElement).blur()" /> px</label>
</SettingRow>
@@ -173,8 +175,8 @@ async function changePassword() {
<template v-else-if="section === 'editor'">
<h3 class="rm-settings-group-title">正文与代码</h3>
<SettingRow id="text-font" control="rm-text-font" resettable @reset="change({ textFont: DEFAULTS.textFont })"><select id="rm-text-font" :value="settings.textFont" @change="change({ textFont: ($event.target as HTMLSelectElement).value as Settings['textFont'] })"><option v-for="(font, id) in TEXT_FONTS" :key="id" :value="id">{{ font.label }}</option></select></SettingRow>
<SettingRow id="code-font" control="rm-code-font" resettable @reset="change({ codeFont: DEFAULTS.codeFont })"><select id="rm-code-font" :value="settings.codeFont" @change="change({ codeFont: ($event.target as HTMLSelectElement).value as Settings['codeFont'] })"><option v-for="(font, id) in CODE_FONTS" :key="id" :value="id">{{ font.label }}</option></select></SettingRow>
<SettingRow id="text-font" control="rm-text-font" resettable @reset="change({ textFont: DEFAULTS.textFont })"><FontPicker id="rm-text-font" setting="textFont" /></SettingRow>
<SettingRow id="code-font" control="rm-code-font" resettable @reset="change({ codeFont: DEFAULTS.codeFont })"><FontPicker id="rm-code-font" setting="codeFont" /></SettingRow>
<SettingRow id="font-size" control="rm-font-size" resettable @reset="change({ fontSize: DEFAULTS.fontSize })"><label class="rm-font-input"><input id="rm-font-size" type="number" min="0" step="any" :value="settings.fontSize" @input="changeFont" @blur="changeFont" @keydown.enter="($event.target as HTMLInputElement).blur()" /> px</label></SettingRow>
<SettingRow id="content-width" resettable @reset="change({ contentWidth: DEFAULTS.contentWidth })"><div class="rm-segmented" role="radiogroup" aria-label="正文宽度"><button v-for="w in CONTENT_WIDTHS" :key="w.value" role="radio" :aria-checked="settings.contentWidth === w.value" :class="{ on: settings.contentWidth === w.value }" @click="change({ contentWidth: w.value })">{{ w.label }}</button></div></SettingRow>
<SettingRow id="line-height" control="rm-line-height" resettable @reset="change({ lineHeight: DEFAULTS.lineHeight })"><input id="rm-line-height" type="range" min="1.2" max="2.5" step="0.05" :value="settings.lineHeight" @change="change({ lineHeight: Number(($event.target as HTMLInputElement).value) })" /><output for="rm-line-height">{{ settings.lineHeight.toFixed(2) }}</output></SettingRow>
+9 -1
View File
@@ -24,6 +24,8 @@ import { flushAll, readyToReload, startWorkspace, stopWorkspace, toast, ws } fro
const emit = defineEmits<{ logout: [] }>()
const title = computed(() => (ws.active ? noteTitle(ws.active) : 'RawMark'))
const resizingSidebar = ref(false)
// 自定义字体需登录后读取;初始化期间保留排版,但不先显示默认字体。
const initializing = ref(true)
// 拖动侧栏右缘调整宽度:拖动中只改 CSS 变量,松手后保存到设置
function startResize(e: PointerEvent) {
@@ -91,7 +93,11 @@ function onTouchMove(e: TouchEvent) {
const unregisterLogout = registerCommand({ id: 'app.logout', title: '退出登录', run: () => void logout() })
onMounted(() => void startWorkspace().catch((err) => toast('工作区初始化失败,请重新加载:' + (err instanceof ApiError ? err.message : '网络或本地存储错误'))))
onMounted(async () => {
try { await startWorkspace() }
catch (err) { toast('工作区初始化失败,请重新加载:' + (err instanceof ApiError ? err.message : '网络或本地存储错误')) }
finally { initializing.value = false }
})
onBeforeUnmount(() => {
unregisterLogout()
stopWorkspace()
@@ -101,6 +107,8 @@ onBeforeUnmount(() => {
<template>
<div
class="rm-shell"
:style="initializing ? { opacity: 0, pointerEvents: 'none' } : undefined"
:inert="initializing"
:class="{ 'sidebar-open': ws.sidebarOpen, 'sidebar-collapsed': ws.sidebarCollapsed, 'sidebar-resizing': resizingSidebar, 'focus-mode': ws.focusMode }"
@touchstart.passive="onTouchStart"
@touchmove.passive="onTouchMove"
+13
View File
@@ -35,6 +35,12 @@ export interface Theme {
version: string
}
export interface CustomFont {
id: string
name: string
format: 'ttf' | 'otf' | 'woff' | 'woff2'
}
export interface InstalledPlugin {
id: string
name: string
@@ -123,6 +129,13 @@ export const api = {
changePassword: (current: string, next: string) => request('POST', '/api/v1/auth/password', { json: { current, next } }),
settings: () => json<Record<string, unknown>>(request('GET', '/api/v1/settings')),
fonts: async () => (await json<{ fonts: CustomFont[] }>(request('GET', '/api/v1/fonts'))).fonts,
importFont: (file: File) => {
const body = new FormData()
body.append('file', file)
return json<CustomFont>(request('POST', '/api/v1/fonts', { body }))
},
deleteFont: (id: string) => request('DELETE', '/api/v1/fonts/' + encodeURIComponent(id)),
themes: async () => (await json<{ themes: Theme[] }>(request('GET', '/api/v1/themes'))).themes,
theme: (id: string) => json<Theme & { css: string }>(request('GET', '/api/v1/themes/' + encodeURIComponent(id))),
plugins: async () => (await json<{ plugins: InstalledPlugin[] }>(request('GET', '/api/v1/plugins'))).plugins,
+21
View File
@@ -0,0 +1,21 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest'
import { applyFontCatalog, parseFontCatalog } from './fonts'
describe('导入字体资源边界', () => {
it('拒绝可插入CSS的标识和不支持的格式', () => {
for (const value of [null, {}, [{}], [{ id: "custom-');}body{color:red}", name: 'bad', format: 'woff2' }], [{ id: 'custom-' + 'a'.repeat(64), name: 'bad', format: 'exe' }]]) {
expect(() => parseFontCatalog(value)).toThrow('字体列表格式无效')
}
})
it('字体名称只供界面显示,不进入CSS;设置同步不重复替换相同字体声明', () => {
const fonts = parseFontCatalog([{ id: 'custom-' + 'a'.repeat(64), name: "bad');}body{color:red}", format: 'woff2' }])
applyFontCatalog(fonts)
const style = document.querySelector('#rm-custom-fonts')!
expect(style.textContent).not.toContain('body{color:red}')
const text = style.firstChild
applyFontCatalog(fonts)
expect(style.firstChild).toBe(text)
applyFontCatalog([])
})
})
+22
View File
@@ -0,0 +1,22 @@
import { shallowRef } from 'vue'
import { api, type CustomFont } from './api'
export const customFonts = shallowRef<CustomFont[]>([])
export function applyFontCatalog(fonts: CustomFont[]) {
customFonts.value = fonts
let style = document.getElementById('rm-custom-fonts')
if (!style) { style = document.createElement('style'); style.id = 'rm-custom-fonts'; document.head.append(style) }
// 名称仅用于界面;CSS 标识和资源路径只由服务端内容哈希构成。
const css = fonts.map(font => `@font-face { font-family: '${font.id}'; src: url('/api/v1/fonts/${font.id}/file'); font-display: block; }`).join('\n')
if (style.textContent !== css) style.textContent = css
}
export function parseFontCatalog(value: unknown): CustomFont[] {
if (!Array.isArray(value) || value.some(font => !font || typeof font !== 'object' || !/^custom-[a-f0-9]{64}$/.test(font.id) || typeof font.name !== 'string' || !['ttf', 'otf', 'woff', 'woff2'].includes(font.format))) throw new Error('字体列表格式无效')
return value
}
export async function loadFonts() {
applyFontCatalog(parseFontCatalog(await api.fonts()))
}
+73
View File
@@ -8,15 +8,18 @@ import ShortcutSettings from './ShortcutSettings.vue'
import { DEFAULTS, loadSettings, settings, settingsSave, SettingsAppearanceError, updateSettings } from './settings'
import { searchSettings } from './settingsCatalog'
import { ws } from './workspace'
import { applyFontCatalog } from './fonts'
let app: App | undefined
let host: HTMLElement | undefined
const cleanup: (() => void)[] = []
beforeEach(() => {
applyFontCatalog([])
Object.assign(settings, DEFAULTS, { colorMode: 'system', theme: 'default', cssSnippets: [], shortcuts: {} })
Object.assign(settingsSave, { pending: 0, result: 'idle', error: '' })
vi.spyOn(api, 'patchSettings').mockResolvedValue({})
vi.spyOn(api, 'settings').mockResolvedValue({})
vi.spyOn(api, 'fonts').mockResolvedValue([])
vi.spyOn(api, 'themes').mockResolvedValue([{ id: 'default', name: '默认', version: '1' }])
})
afterEach(() => {
@@ -70,6 +73,76 @@ describe('设置查找与重置', () => {
expect(host!.textContent).not.toContain('模板')
})
it('界面与编辑器共用字体管理,删除预设同步恢复使用位置,Maple不可删除', async () => {
settings.uiFont = settings.textFont = settings.codeFont = 'serif'
ws.settings = 'appearance'
mount(SettingsDialog)
host!.querySelector<HTMLButtonElement>('#rm-ui-font')!.click()
await nextTick()
expect(host!.querySelector('button[aria-label^="删除字体 Maple"]')).toBeNull()
host!.querySelector<HTMLButtonElement>('button[aria-label="删除字体 Noto Serif SC · 思源宋体"]')!.click()
await vi.waitFor(() => expect(settings.hiddenFonts).toContain('serif'))
expect(settings.uiFont).toBe('maple')
expect(settings.textFont).toBe('maple')
expect(settings.codeFont).toBe('maple')
const editor = [...host!.querySelectorAll<HTMLButtonElement>('.rm-settings-tab')].find(button => button.textContent === '编辑器')!
editor.click()
await nextTick()
host!.querySelector<HTMLButtonElement>('#rm-text-font')!.click()
await nextTick()
expect(host!.querySelector('.rm-font-options')!.textContent).not.toContain('Noto Serif SC')
expect(host!.querySelector('.rm-font-options')!.textContent).toContain('导入字体')
})
it('先装载字体资源再恢复服务器上的自定义字体,字体删除后恢复Maple', async () => {
const font = { id: 'custom-' + 'a'.repeat(64), name: '导入字体', format: 'woff2' as const }
vi.mocked(api.fonts).mockResolvedValue([font])
vi.mocked(api.settings).mockResolvedValue({ uiFont: font.id, textFont: font.id, codeFont: font.id })
await loadSettings()
expect(settings.uiFont).toBe(font.id)
expect(document.querySelector('#rm-custom-fonts')!.textContent).toContain('/api/v1/fonts/' + font.id + '/file')
expect(document.documentElement.style.getPropertyValue('--rm-font-text')).toContain(font.id)
vi.mocked(api.fonts).mockResolvedValue([])
await loadSettings()
expect(settings.uiFont).toBe('maple')
expect(settings.textFont).toBe('maple')
expect(settings.codeFont).toBe('maple')
})
it('字体保存失败后重新读取原选择并显示错误', async () => {
vi.mocked(api.patchSettings).mockRejectedValueOnce(new ApiError(500, '字体设置写入失败'))
ws.settings = 'appearance'
mount(SettingsDialog)
host!.querySelector<HTMLButtonElement>('#rm-ui-font')!.click()
await nextTick()
const serif = [...host!.querySelectorAll<HTMLButtonElement>('.rm-font-choice')].find(button => button.textContent === 'Noto Serif SC · 思源宋体')!
serif.click()
await vi.waitFor(() => expect(host!.querySelector('[role="alert"]')!.textContent).toContain('字体设置写入失败'))
expect(settings.uiFont).toBe('maple')
expect(host!.querySelector<HTMLButtonElement>('#rm-ui-font')!.textContent).toContain('Maple')
})
it('删除字体失败保留原字体和设置,允许重试', async () => {
const font = { id: 'custom-' + 'b'.repeat(64), name: '不可删除测试', format: 'woff2' as const }
applyFontCatalog([font])
settings.uiFont = font.id
vi.spyOn(api, 'deleteFont').mockRejectedValueOnce(new ApiError(500, '字体删除失败'))
ws.settings = 'appearance'
mount(SettingsDialog)
host!.querySelector<HTMLButtonElement>('#rm-ui-font')!.click()
await nextTick()
host!.querySelector<HTMLButtonElement>('button[aria-label="删除字体 不可删除测试"]')!.click()
await vi.waitFor(() => expect(host!.querySelector('[role="alert"]')!.textContent).toContain('字体删除失败'))
expect(settings.uiFont).toBe(font.id)
expect(host!.querySelector<HTMLButtonElement>('#rm-ui-font')!.disabled).toBe(false)
})
it('忽略隐藏Maple的外部设置,不允许移除默认字体', async () => {
vi.mocked(api.settings).mockResolvedValue({ hiddenFonts: ['maple', 'serif'], uiFont: 'serif' })
await loadSettings()
expect(settings.hiddenFonts).toEqual(['serif'])
expect(settings.uiFont).toBe('maple')
})
it('设置和命令不再提供分享入口', async () => {
expect(searchSettings('分享')).toEqual([])
expect(allCommands().map(command => command.id)).not.toContain('note.share')
+9 -4
View File
@@ -1,7 +1,8 @@
// 应用设置:服务器上一份 JSON,全部设备共用;修改立即保存,其他设备经事件推送同步。
// 本地缓存上次的值,启动时先套用,避免页面先按默认值显示再跳变
import { reactive } from 'vue'
import { DISPLAY_DEFAULTS, sanitizeDisplaySettings, applyDisplaySettings } from '../themes/typography'
import { applyFontCatalog, customFonts, parseFontCatalog } from './fonts'
import { DISPLAY_DEFAULTS, FONT_PRESETS, sanitizeDisplaySettings, applyDisplaySettings } from '../themes/typography'
import { api } from './api'
import { normalizeKeys } from '../keys'
import { applyAppearance, appearancePaused, sanitizeAppearance, type Appearance } from '../themes/appearance'
@@ -17,7 +18,7 @@ export const CONTENT_WIDTHS = [
export const SIDEBAR_WIDTH = { min: 200, max: 480 }
export const DEFAULTS = { selectionToolbar: true, ...DISPLAY_DEFAULTS, sidebarWidth: 288, spellcheck: false, tabSize: 4 }
export const DEFAULTS = { hiddenFonts: [] as string[], selectionToolbar: true, ...DISPLAY_DEFAULTS, sidebarWidth: 288, spellcheck: false, tabSize: 4 }
export type Settings = typeof DEFAULTS & Appearance & { shortcuts: Record<string, string[]> }
const CACHE_KEY = 'rawmark.settings'
@@ -35,8 +36,10 @@ export class SettingsAppearanceError extends Error {
// 服务器上的设置可能由旧版本、API 或插件写入,取值不合法的项退回默认值
function sanitize(raw: Record<string, unknown>): Settings {
const pick = <T>(v: unknown, allowed: T[], def: T) => (allowed.includes(v as T) ? (v as T) : def)
const hiddenFonts = Array.isArray(raw.hiddenFonts) ? raw.hiddenFonts.filter((id): id is string => typeof id === 'string' && id !== 'maple' && Object.hasOwn(FONT_PRESETS, id)) : []
return {
...sanitizeDisplaySettings(raw),
hiddenFonts,
...sanitizeDisplaySettings(raw, customFonts.value.map(font => font.id), hiddenFonts),
selectionToolbar: typeof raw.selectionToolbar === 'boolean' ? raw.selectionToolbar : true,
shortcuts: sanitizeShortcuts(raw.shortcuts),
spellcheck: typeof raw.spellcheck === 'boolean' ? raw.spellcheck : DEFAULTS.spellcheck,
@@ -92,7 +95,9 @@ applied({ ...settings })
applyAppearance({ ...settings, theme: 'default', cssSnippets: [] }, '')
export async function loadSettings() {
applied(sanitize(await api.settings()))
const [raw, fonts] = await Promise.all([api.settings(), api.fonts()])
applyFontCatalog(parseFontCatalog(fonts))
applied(sanitize(raw))
await applySettingsAppearance()
}
+1
View File
@@ -13,6 +13,7 @@ export const SETTINGS_SECTIONS: { id: SettingsSection; title: string; icon: Icon
]
export const SETTING_ITEMS = {
'ui-font': { section: 'appearance', title: '界面字体', description: '用于菜单、侧栏与按钮;可导入或删除字体,Maple 为不可删除的默认字体。', keywords: 'UI 字体 导入 删除 TTF OTF WOFF' },
'ui-font-size': { section: 'appearance', title: '界面字号', description: '调整菜单、侧栏与按钮文字,不影响正文和代码字号。', keywords: 'UI 大小' },
'theme-select': { section: 'appearance', title: '主题', description: '使用内置主题,或从服务器数据目录的 themes 文件夹添加主题。', keywords: '白色 黑色 自定义' },
'color-mode': { section: 'appearance', title: '明暗模式', description: '选择浅色、深色,或跟随系统切换。', keywords: '黑暗 夜间 light dark system' },
+3
View File
@@ -24,6 +24,7 @@ describe('事件同步', () => {
it('目录移动映射整个已打开的子树,并把未保存输入写入新路径', async () => {
const tree = vi.spyOn(api, 'tree').mockResolvedValue([{ path: 'from/note.md', dir: false, size: 0, mtime: 0 }])
vi.spyOn(api, 'settings').mockResolvedValue({})
vi.spyOn(api, 'fonts').mockResolvedValue([])
vi.spyOn(api, 'plugins').mockResolvedValue([])
vi.spyOn(drafts, 'all').mockResolvedValue([])
vi.spyOn(drafts, 'put').mockImplementation(async (draft) => draft.path)
@@ -48,6 +49,7 @@ describe('事件同步', () => {
const entries = [{ path: 'sync.md', dir: false, size: 0, mtime: 0 }]
vi.spyOn(api, 'tree').mockResolvedValue(entries)
vi.spyOn(api, 'settings').mockResolvedValue({})
vi.spyOn(api, 'fonts').mockResolvedValue([])
vi.spyOn(api, 'plugins').mockResolvedValue([])
vi.spyOn(drafts, 'all').mockResolvedValue([])
vi.spyOn(drafts, 'delete').mockResolvedValue(undefined)
@@ -140,6 +142,7 @@ describe('笔记打开与创建', () => {
expect(ws.toasts.some(toast => toast.text.includes('打开失败:文件不存在'))).toBe(false)
})
})
describe('保存与关闭', () => {
+1 -1
View File
@@ -1037,7 +1037,7 @@ export async function startWorkspace() {
)
// 字号、正文宽度变化后行高随之改变,编辑器需要重新测量
watch(
() => [settings.uiFontSize, settings.fontSize, settings.contentWidth, settings.sidebarWidth, settings.textFont, settings.codeFont, settings.lineHeight, settings.paragraphSpacing],
() => [settings.uiFont, settings.uiFontSize, settings.fontSize, settings.contentWidth, settings.sidebarWidth, settings.textFont, settings.codeFont, settings.lineHeight, settings.paragraphSpacing],
() => view?.requestMeasure(),
)
watch(
+11 -6
View File
@@ -12,26 +12,31 @@ export const CODE_FONTS = {
system: { label: '系统等宽字体', css: 'ui-monospace, monospace' },
}
export const UI_FONT_SIZE = { min: 14, max: 20, default: 14 }
export const DISPLAY_DEFAULTS = { uiFontSize: UI_FONT_SIZE.default, fontSize: 16, contentWidth: 760, textFont: 'maple' as keyof typeof TEXT_FONTS, codeFont: 'maple' as keyof typeof CODE_FONTS, lineHeight: 1.75, paragraphSpacing: 16, glassOpacity: .75 }
export const FONT_PRESETS: Record<string, { label: string; css: string }> = { ...TEXT_FONTS, jetbrains: CODE_FONTS.jetbrains }
export const DISPLAY_DEFAULTS = { uiFont: 'maple', uiFontSize: UI_FONT_SIZE.default, fontSize: 16, contentWidth: 760, textFont: 'maple', codeFont: 'maple', lineHeight: 1.75, paragraphSpacing: 16, glassOpacity: .75 }
export type DisplaySettings = typeof DISPLAY_DEFAULTS
export function sanitizeDisplaySettings(raw: Record<string, unknown>): DisplaySettings {
export function sanitizeDisplaySettings(raw: Record<string, unknown>, customIDs: string[] = [], hiddenFonts: string[] = []): 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]
const font = (value: unknown) => typeof value === 'string' && !hiddenFonts.includes(value) && (Object.hasOwn(FONT_PRESETS, value) || customIDs.includes(value)) ? value : 'maple'
return {
uiFont: font(raw.uiFont),
uiFontSize: typeof raw.uiFontSize === 'number' && Number.isInteger(raw.uiFontSize) && raw.uiFontSize >= UI_FONT_SIZE.min && raw.uiFontSize <= UI_FONT_SIZE.max ? raw.uiFontSize : UI_FONT_SIZE.default,
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 === 'sans' || raw.textFont === 'serif' || raw.textFont === 'system' ? raw.textFont : 'maple',
codeFont: raw.codeFont === 'jetbrains' || raw.codeFont === 'system' ? raw.codeFont : 'maple',
textFont: font(raw.textFont),
codeFont: font(raw.codeFont),
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
const css = (id: string, code = false) => id === 'system' && code ? CODE_FONTS.system.css : FONT_PRESETS[id]?.css ?? `'${id}', ${maple.css}`
root.setProperty('--rm-font-text', css(settings.uiFont))
root.setProperty('--rm-ui-font-size', settings.uiFontSize + 'px')
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-font-editor', css(settings.textFont))
root.setProperty('--rm-font-mono', css(settings.codeFont, true))
root.setProperty('--rm-line-height', String(settings.lineHeight))
root.setProperty('--rm-paragraph-spacing', settings.paragraphSpacing + 'px')
root.setProperty('--rm-glass-opacity', String(settings.glassOpacity))