feat(settings): manage shared interface and editor fonts
This commit is contained in:
1 parent
1d5637a5eb
commit
1882312435
19 files changed
+849
-16
No files matched your search
@@ -33,7 +33,7 @@ docker run -d --name rawmark \
|
|||||||
- 首次打开直接设置登录密码,之后凭密码登录;请在将新实例开放给他人访问前完成初始化。
|
- 首次打开直接设置登录密码,之后凭密码登录;请在将新实例开放给他人访问前完成初始化。
|
||||||
- 笔记以普通 `.md` 文件保存在 `数据目录/vault/`,粘贴、上传和导入的图片统一存放在笔记同级的 `assets/`,链接使用 `./assets/文件名`,同目录相同内容去重。笔记跨目录移动时图片跟随,仍被其他笔记引用的原图保留;删除单篇笔记不会删除共享图片。旧 `笔记名.assets/` 仍可读取、移动与恢复;外部修改会实时同步到网页。
|
- 笔记以普通 `.md` 文件保存在 `数据目录/vault/`,粘贴、上传和导入的图片统一存放在笔记同级的 `assets/`,链接使用 `./assets/文件名`,同目录相同内容去重。笔记跨目录移动时图片跟随,仍被其他笔记引用的原图保留;删除单篇笔记不会删除共享图片。旧 `笔记名.assets/` 仍可读取、移动与恢复;外部修改会实时同步到网页。
|
||||||
- 知识工具:标准 Markdown 链接构成图谱,支持正文 `#标签` 与 YAML `tags`。笔记或文件夹在应用内改名、移动时维护入链和相对出链;公式和代码示例不参与关系提取。
|
- 知识工具:标准 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 支持。清除样式只重置局部外观;底部不再显示格式栏。
|
- 选区格式:电脑与触屏选中文字后提供外观、加粗、斜体、下划线、删除线、高亮和链接。外观可设置配色、字体、局部字号与效果;下划线及外观以受限的标准 HTML 行内标签保存在 Markdown 中,源码与导出保留标签,其他软件的显示取决于其 HTML 支持。清除样式只重置局部外观;底部不再显示格式栏。
|
||||||
- 图片管理:侧栏默认隐藏 `assets` 和 `笔记名.assets`;右键文件夹选择「图片管理」,查看当前目录及直属附件目录的图片,支持检索、排序、网格/列表、批量上传、引用查看及预览、重命名、移动、替换。可切换整个知识库;清理孤立图片须确认候选,服务端再次核对全库引用后移入回收站,可恢复。上传至所选目录的 `assets/`,相同内容自动去重。根目录可在侧栏空白处右键或通过命令面板打开。图片改名/移动会更新笔记中的本地引用;替换保留路径,选择相同扩展名的图片(最大 50 MB),旧图片不进回收站。
|
- 图片管理:侧栏默认隐藏 `assets` 和 `笔记名.assets`;右键文件夹选择「图片管理」,查看当前目录及直属附件目录的图片,支持检索、排序、网格/列表、批量上传、引用查看及预览、重命名、移动、替换。可切换整个知识库;清理孤立图片须确认候选,服务端再次核对全库引用后移入回收站,可恢复。上传至所选目录的 `assets/`,相同内容自动去重。根目录可在侧栏空白处右键或通过命令面板打开。图片改名/移动会更新笔记中的本地引用;替换保留路径,选择相同扩展名的图片(最大 50 MB),旧图片不进回收站。
|
||||||
- `--user` 让容器以宿主机用户身份读写数据目录;不指定时容器以 UID 65532 运行,需要该用户对目录有写权限。
|
- `--user` 让容器以宿主机用户身份读写数据目录;不指定时容器以 UID 65532 运行,需要该用户对目录有写权限。
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ func run(addr string) error {
|
|||||||
srv := api.New(v, am, st, hub, idx)
|
srv := api.New(v, am, st, hub, idx)
|
||||||
srv.ThemesDir = filepath.Join(dataDir, "themes")
|
srv.ThemesDir = filepath.Join(dataDir, "themes")
|
||||||
srv.PluginsDir = filepath.Join(dataDir, "plugins")
|
srv.PluginsDir = filepath.Join(dataDir, "plugins")
|
||||||
|
srv.FontsDir = filepath.Join(dataDir, "fonts")
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
Handler: srv.Handler(web.Dist()),
|
Handler: srv.Handler(web.Dist()),
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ type Server struct {
|
|||||||
// ThemesDir 指向数据目录中的主题目录;为空时只有内置主题。
|
// ThemesDir 指向数据目录中的主题目录;为空时只有内置主题。
|
||||||
ThemesDir string
|
ThemesDir string
|
||||||
PluginsDir string
|
PluginsDir string
|
||||||
|
FontsDir string
|
||||||
pluginsMu sync.Mutex
|
pluginsMu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +99,10 @@ func (s *Server) routes() []route {
|
|||||||
|
|
||||||
{"GET /api/v1/settings", authed, s.getSettings},
|
{"GET /api/v1/settings", authed, s.getSettings},
|
||||||
{"PATCH /api/v1/settings", sessionOnly, s.patchSettings},
|
{"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},
|
{"GET /api/v1/plugins", sessionOnly, s.listPlugins},
|
||||||
{"POST /api/v1/plugins", sessionOnly, s.installPlugin},
|
{"POST /api/v1/plugins", sessionOnly, s.installPlugin},
|
||||||
{"PATCH /api/v1/plugins/{id}", sessionOnly, s.togglePlugin},
|
{"PATCH /api/v1/plugins/{id}", sessionOnly, s.togglePlugin},
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ func newFixture(t *testing.T) *fixture {
|
|||||||
server := New(v, am, st, hub, idx)
|
server := New(v, am, st, hub, idx)
|
||||||
server.ThemesDir = filepath.Join(dir, "themes")
|
server.ThemesDir = filepath.Join(dir, "themes")
|
||||||
server.PluginsDir = filepath.Join(dir, "plugins")
|
server.PluginsDir = filepath.Join(dir, "plugins")
|
||||||
|
server.FontsDir = filepath.Join(dir, "fonts")
|
||||||
srv := httptest.NewServer(server.Handler(fstest.MapFS{}))
|
srv := httptest.NewServer(server.Handler(fstest.MapFS{}))
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
f := &fixture{t: t, url: srv.URL, root: v.Root(), store: st, hub: hub, admin: newClient()}
|
f := &fixture{t: t, url: srv.URL, root: v.Root(), store: st, hub: hub, admin: newClient()}
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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("失败后字体未恢复")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": {
|
"components": {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -4,7 +4,8 @@ import { api, ApiError } from './api'
|
|||||||
import { runCommand } from './commands'
|
import { runCommand } from './commands'
|
||||||
import Icon from './Icon.vue'
|
import Icon from './Icon.vue'
|
||||||
import AppearanceSettings from './AppearanceSettings.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 PluginsPanel from './PluginsPanel.vue'
|
||||||
import { installApp, installPrompt } from './pwa'
|
import { installApp, installPrompt } from './pwa'
|
||||||
import { CONTENT_WIDTHS, DEFAULTS, TAB_SIZES, loadSettings, settings, settingsSave, SettingsAppearanceError, updateSettings, type Settings } from './settings'
|
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">
|
<div v-show="!searching" class="rm-settings-page">
|
||||||
<PluginsPanel v-if="section === 'plugins'" />
|
<PluginsPanel v-if="section === 'plugins'" />
|
||||||
<template v-else-if="section === 'appearance'">
|
<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 })">
|
<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>
|
<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>
|
</SettingRow>
|
||||||
@@ -173,8 +175,8 @@ async function changePassword() {
|
|||||||
|
|
||||||
<template v-else-if="section === 'editor'">
|
<template v-else-if="section === 'editor'">
|
||||||
<h3 class="rm-settings-group-title">正文与代码</h3>
|
<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="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 })"><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="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="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="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>
|
<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>
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import { flushAll, readyToReload, startWorkspace, stopWorkspace, toast, ws } fro
|
|||||||
const emit = defineEmits<{ logout: [] }>()
|
const emit = defineEmits<{ logout: [] }>()
|
||||||
const title = computed(() => (ws.active ? noteTitle(ws.active) : 'RawMark'))
|
const title = computed(() => (ws.active ? noteTitle(ws.active) : 'RawMark'))
|
||||||
const resizingSidebar = ref(false)
|
const resizingSidebar = ref(false)
|
||||||
|
// 自定义字体需登录后读取;初始化期间保留排版,但不先显示默认字体。
|
||||||
|
const initializing = ref(true)
|
||||||
|
|
||||||
// 拖动侧栏右缘调整宽度:拖动中只改 CSS 变量,松手后保存到设置
|
// 拖动侧栏右缘调整宽度:拖动中只改 CSS 变量,松手后保存到设置
|
||||||
function startResize(e: PointerEvent) {
|
function startResize(e: PointerEvent) {
|
||||||
@@ -91,7 +93,11 @@ function onTouchMove(e: TouchEvent) {
|
|||||||
|
|
||||||
const unregisterLogout = registerCommand({ id: 'app.logout', title: '退出登录', run: () => void logout() })
|
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(() => {
|
onBeforeUnmount(() => {
|
||||||
unregisterLogout()
|
unregisterLogout()
|
||||||
stopWorkspace()
|
stopWorkspace()
|
||||||
@@ -101,6 +107,8 @@ onBeforeUnmount(() => {
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="rm-shell"
|
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 }"
|
:class="{ 'sidebar-open': ws.sidebarOpen, 'sidebar-collapsed': ws.sidebarCollapsed, 'sidebar-resizing': resizingSidebar, 'focus-mode': ws.focusMode }"
|
||||||
@touchstart.passive="onTouchStart"
|
@touchstart.passive="onTouchStart"
|
||||||
@touchmove.passive="onTouchMove"
|
@touchmove.passive="onTouchMove"
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ export interface Theme {
|
|||||||
version: string
|
version: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CustomFont {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
format: 'ttf' | 'otf' | 'woff' | 'woff2'
|
||||||
|
}
|
||||||
|
|
||||||
export interface InstalledPlugin {
|
export interface InstalledPlugin {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -123,6 +129,13 @@ export const api = {
|
|||||||
changePassword: (current: string, next: string) => request('POST', '/api/v1/auth/password', { json: { current, next } }),
|
changePassword: (current: string, next: string) => request('POST', '/api/v1/auth/password', { json: { current, next } }),
|
||||||
|
|
||||||
settings: () => json<Record<string, unknown>>(request('GET', '/api/v1/settings')),
|
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,
|
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))),
|
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,
|
plugins: async () => (await json<{ plugins: InstalledPlugin[] }>(request('GET', '/api/v1/plugins'))).plugins,
|
||||||
|
|||||||
@@ -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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()))
|
||||||
|
}
|
||||||
@@ -8,15 +8,18 @@ import ShortcutSettings from './ShortcutSettings.vue'
|
|||||||
import { DEFAULTS, loadSettings, settings, settingsSave, SettingsAppearanceError, updateSettings } from './settings'
|
import { DEFAULTS, loadSettings, settings, settingsSave, SettingsAppearanceError, updateSettings } from './settings'
|
||||||
import { searchSettings } from './settingsCatalog'
|
import { searchSettings } from './settingsCatalog'
|
||||||
import { ws } from './workspace'
|
import { ws } from './workspace'
|
||||||
|
import { applyFontCatalog } from './fonts'
|
||||||
|
|
||||||
let app: App | undefined
|
let app: App | undefined
|
||||||
let host: HTMLElement | undefined
|
let host: HTMLElement | undefined
|
||||||
const cleanup: (() => void)[] = []
|
const cleanup: (() => void)[] = []
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
applyFontCatalog([])
|
||||||
Object.assign(settings, DEFAULTS, { colorMode: 'system', theme: 'default', cssSnippets: [], shortcuts: {} })
|
Object.assign(settings, DEFAULTS, { colorMode: 'system', theme: 'default', cssSnippets: [], shortcuts: {} })
|
||||||
Object.assign(settingsSave, { pending: 0, result: 'idle', error: '' })
|
Object.assign(settingsSave, { pending: 0, result: 'idle', error: '' })
|
||||||
vi.spyOn(api, 'patchSettings').mockResolvedValue({})
|
vi.spyOn(api, 'patchSettings').mockResolvedValue({})
|
||||||
vi.spyOn(api, 'settings').mockResolvedValue({})
|
vi.spyOn(api, 'settings').mockResolvedValue({})
|
||||||
|
vi.spyOn(api, 'fonts').mockResolvedValue([])
|
||||||
vi.spyOn(api, 'themes').mockResolvedValue([{ id: 'default', name: '默认', version: '1' }])
|
vi.spyOn(api, 'themes').mockResolvedValue([{ id: 'default', name: '默认', version: '1' }])
|
||||||
})
|
})
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -70,6 +73,76 @@ describe('设置查找与重置', () => {
|
|||||||
expect(host!.textContent).not.toContain('模板')
|
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 () => {
|
it('设置和命令不再提供分享入口', async () => {
|
||||||
expect(searchSettings('分享')).toEqual([])
|
expect(searchSettings('分享')).toEqual([])
|
||||||
expect(allCommands().map(command => command.id)).not.toContain('note.share')
|
expect(allCommands().map(command => command.id)).not.toContain('note.share')
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// 应用设置:服务器上一份 JSON,全部设备共用;修改立即保存,其他设备经事件推送同步。
|
// 应用设置:服务器上一份 JSON,全部设备共用;修改立即保存,其他设备经事件推送同步。
|
||||||
// 本地缓存上次的值,启动时先套用,避免页面先按默认值显示再跳变
|
// 本地缓存上次的值,启动时先套用,避免页面先按默认值显示再跳变
|
||||||
import { reactive } from 'vue'
|
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 { api } from './api'
|
||||||
import { normalizeKeys } from '../keys'
|
import { normalizeKeys } from '../keys'
|
||||||
import { applyAppearance, appearancePaused, sanitizeAppearance, type Appearance } from '../themes/appearance'
|
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 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[]> }
|
export type Settings = typeof DEFAULTS & Appearance & { shortcuts: Record<string, string[]> }
|
||||||
|
|
||||||
const CACHE_KEY = 'rawmark.settings'
|
const CACHE_KEY = 'rawmark.settings'
|
||||||
@@ -35,8 +36,10 @@ export class SettingsAppearanceError extends Error {
|
|||||||
// 服务器上的设置可能由旧版本、API 或插件写入,取值不合法的项退回默认值
|
// 服务器上的设置可能由旧版本、API 或插件写入,取值不合法的项退回默认值
|
||||||
function sanitize(raw: Record<string, unknown>): Settings {
|
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 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 {
|
return {
|
||||||
...sanitizeDisplaySettings(raw),
|
hiddenFonts,
|
||||||
|
...sanitizeDisplaySettings(raw, customFonts.value.map(font => font.id), hiddenFonts),
|
||||||
selectionToolbar: typeof raw.selectionToolbar === 'boolean' ? raw.selectionToolbar : true,
|
selectionToolbar: typeof raw.selectionToolbar === 'boolean' ? raw.selectionToolbar : true,
|
||||||
shortcuts: sanitizeShortcuts(raw.shortcuts),
|
shortcuts: sanitizeShortcuts(raw.shortcuts),
|
||||||
spellcheck: typeof raw.spellcheck === 'boolean' ? raw.spellcheck : DEFAULTS.spellcheck,
|
spellcheck: typeof raw.spellcheck === 'boolean' ? raw.spellcheck : DEFAULTS.spellcheck,
|
||||||
@@ -92,7 +95,9 @@ applied({ ...settings })
|
|||||||
applyAppearance({ ...settings, theme: 'default', cssSnippets: [] }, '')
|
applyAppearance({ ...settings, theme: 'default', cssSnippets: [] }, '')
|
||||||
|
|
||||||
export async function loadSettings() {
|
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()
|
await applySettingsAppearance()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const SETTINGS_SECTIONS: { id: SettingsSection; title: string; icon: Icon
|
|||||||
]
|
]
|
||||||
|
|
||||||
export const SETTING_ITEMS = {
|
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 大小' },
|
'ui-font-size': { section: 'appearance', title: '界面字号', description: '调整菜单、侧栏与按钮文字,不影响正文和代码字号。', keywords: 'UI 大小' },
|
||||||
'theme-select': { section: 'appearance', title: '主题', description: '使用内置主题,或从服务器数据目录的 themes 文件夹添加主题。', keywords: '白色 黑色 自定义' },
|
'theme-select': { section: 'appearance', title: '主题', description: '使用内置主题,或从服务器数据目录的 themes 文件夹添加主题。', keywords: '白色 黑色 自定义' },
|
||||||
'color-mode': { section: 'appearance', title: '明暗模式', description: '选择浅色、深色,或跟随系统切换。', keywords: '黑暗 夜间 light dark system' },
|
'color-mode': { section: 'appearance', title: '明暗模式', description: '选择浅色、深色,或跟随系统切换。', keywords: '黑暗 夜间 light dark system' },
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ describe('事件同步', () => {
|
|||||||
it('目录移动映射整个已打开的子树,并把未保存输入写入新路径', async () => {
|
it('目录移动映射整个已打开的子树,并把未保存输入写入新路径', async () => {
|
||||||
const tree = vi.spyOn(api, 'tree').mockResolvedValue([{ path: 'from/note.md', dir: false, size: 0, mtime: 0 }])
|
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, 'settings').mockResolvedValue({})
|
||||||
|
vi.spyOn(api, 'fonts').mockResolvedValue([])
|
||||||
vi.spyOn(api, 'plugins').mockResolvedValue([])
|
vi.spyOn(api, 'plugins').mockResolvedValue([])
|
||||||
vi.spyOn(drafts, 'all').mockResolvedValue([])
|
vi.spyOn(drafts, 'all').mockResolvedValue([])
|
||||||
vi.spyOn(drafts, 'put').mockImplementation(async (draft) => draft.path)
|
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 }]
|
const entries = [{ path: 'sync.md', dir: false, size: 0, mtime: 0 }]
|
||||||
vi.spyOn(api, 'tree').mockResolvedValue(entries)
|
vi.spyOn(api, 'tree').mockResolvedValue(entries)
|
||||||
vi.spyOn(api, 'settings').mockResolvedValue({})
|
vi.spyOn(api, 'settings').mockResolvedValue({})
|
||||||
|
vi.spyOn(api, 'fonts').mockResolvedValue([])
|
||||||
vi.spyOn(api, 'plugins').mockResolvedValue([])
|
vi.spyOn(api, 'plugins').mockResolvedValue([])
|
||||||
vi.spyOn(drafts, 'all').mockResolvedValue([])
|
vi.spyOn(drafts, 'all').mockResolvedValue([])
|
||||||
vi.spyOn(drafts, 'delete').mockResolvedValue(undefined)
|
vi.spyOn(drafts, 'delete').mockResolvedValue(undefined)
|
||||||
@@ -140,6 +142,7 @@ describe('笔记打开与创建', () => {
|
|||||||
expect(ws.toasts.some(toast => toast.text.includes('打开失败:文件不存在'))).toBe(false)
|
expect(ws.toasts.some(toast => toast.text.includes('打开失败:文件不存在'))).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('保存与关闭', () => {
|
describe('保存与关闭', () => {
|
||||||
|
|||||||
@@ -1037,7 +1037,7 @@ export async function startWorkspace() {
|
|||||||
)
|
)
|
||||||
// 字号、正文宽度变化后行高随之改变,编辑器需要重新测量
|
// 字号、正文宽度变化后行高随之改变,编辑器需要重新测量
|
||||||
watch(
|
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(),
|
() => view?.requestMeasure(),
|
||||||
)
|
)
|
||||||
watch(
|
watch(
|
||||||
|
|||||||
@@ -12,26 +12,31 @@ export const CODE_FONTS = {
|
|||||||
system: { label: '系统等宽字体', css: 'ui-monospace, monospace' },
|
system: { label: '系统等宽字体', css: 'ui-monospace, monospace' },
|
||||||
}
|
}
|
||||||
export const UI_FONT_SIZE = { min: 14, max: 20, default: 14 }
|
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 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 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 {
|
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,
|
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,
|
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,
|
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',
|
textFont: font(raw.textFont),
|
||||||
codeFont: raw.codeFont === 'jetbrains' || raw.codeFont === 'system' ? raw.codeFont : 'maple',
|
codeFont: font(raw.codeFont),
|
||||||
lineHeight: ranged('lineHeight', 1.2, 2.5), paragraphSpacing: ranged('paragraphSpacing', 0, 40), glassOpacity: ranged('glassOpacity', .4, 1),
|
lineHeight: ranged('lineHeight', 1.2, 2.5), paragraphSpacing: ranged('paragraphSpacing', 0, 40), glassOpacity: ranged('glassOpacity', .4, 1),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export function applyDisplaySettings(settings: DisplaySettings) {
|
export function applyDisplaySettings(settings: DisplaySettings) {
|
||||||
const root = document.documentElement.style
|
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-ui-font-size', settings.uiFontSize + 'px')
|
||||||
root.setProperty('--rm-font-size', settings.fontSize + 'px')
|
root.setProperty('--rm-font-size', settings.fontSize + 'px')
|
||||||
root.setProperty('--rm-content-width', settings.contentWidth ? settings.contentWidth + 'px' : 'none')
|
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-editor', css(settings.textFont))
|
||||||
root.setProperty('--rm-font-mono', CODE_FONTS[settings.codeFont].css)
|
root.setProperty('--rm-font-mono', css(settings.codeFont, true))
|
||||||
root.setProperty('--rm-line-height', String(settings.lineHeight))
|
root.setProperty('--rm-line-height', String(settings.lineHeight))
|
||||||
root.setProperty('--rm-paragraph-spacing', settings.paragraphSpacing + 'px')
|
root.setProperty('--rm-paragraph-spacing', settings.paragraphSpacing + 'px')
|
||||||
root.setProperty('--rm-glass-opacity', String(settings.glassOpacity))
|
root.setProperty('--rm-glass-opacity', String(settings.glassOpacity))
|
||||||
|
|||||||
Reference in new issue
Block a user