feat(ui): improve readability and fix workspace regressions

Separate interface typography from document text and enlarge controls across the workspace and public reader. Keep tree menus within the viewport and make constrained dialogs scrollable.

Preserve nested image references, escape moved link paths, synchronize note creation and daily-note opening, and hydrate shared headings. Add regression coverage for settings and workspace behavior.
This commit is contained in:
eezd committed 2026-10-23 10:55:53 +08:00
1 parent 4d1339851b
commit a716794e41
31 files changed
+554 -244

No files matched your search

+1 -1
View File
@@ -33,7 +33,7 @@ docker run -d --name rawmark \
- 笔记以普通 `.md` 文件保存在 `数据目录/vault/`,图片按 Typora 习惯存放在同级的 `笔记名.assets/`,可直接用 Typora 等编辑器打开;外部修改会实时同步到网页。
- 知识工具:标准 Markdown 链接构成图谱和反向链接,支持正文 `#标签` 与 YAML `tags`。笔记或文件夹在应用内改名、移动时维护入链和相对出链;公式和代码示例不参与关系提取。
- 日记与模板:默认日记路径为 `Daily/YYYY-MM-DD.md`(本地日期),已有日记直接打开;模板为 `Templates/` 下的普通 Markdown,支持 `{{date}}`、`{{time}}`、`{{title}}`,资源链接按新笔记位置调整。可在设置中修改目录和日记模板。
- 外观:浅色玻璃工作台与深色模式,字体资源随应用本地提供;排版、玻璃透明度、选中文本工具栏均可在设置中调整并即时保存。
- 外观:浅色玻璃工作台与深色模式,字体资源随应用本地提供;界面字号默认 16px,可输入 14–20px 并单独重置,与编辑器及阅读字号独立;排版、玻璃透明度、选中文本工具栏均可在设置中调整并即时保存。
- 图片管理:侧栏默认隐藏 `assets` 和 `笔记名.assets`;右键文件夹选择「图片管理」,查看当前目录及直属附件目录的图片,支持检索、排序、网格/列表、批量上传、引用查看及预览、重命名、移动、替换。可切换整个知识库;清理孤立图片须确认候选,服务端再次核对全库引用与有效分享后移入回收站,可恢复。上传至所选目录的 `assets/`,相同内容自动去重。根目录可在侧栏空白处右键或通过命令面板打开。图片改名/移动会更新笔记中的本地引用;替换保留路径,选择相同扩展名的图片(最大 50 MB),旧图片不进回收站。
- `--user` 让容器以宿主机用户身份读写数据目录;不指定时容器以 UID 65532 运行,需要该用户对目录有写权限。
- 请通过 HTTPS 反向代理(Caddy、Nginx 等)访问,并转发 WebSocket(`/api/v1/events`)。
+7
View File
@@ -1810,6 +1810,13 @@
"dark"
]
},
"uiFontSize": {
"type": "integer",
"minimum": 14,
"maximum": 20,
"default": 16,
"description": "界面字号,与正文排版字号独立"
},
"fontSize": {
"type": "integer"
},
+1 -1
View File
@@ -154,7 +154,7 @@ func (s *Server) publicAppearance(w http.ResponseWriter, r *http.Request) {
return
}
out := map[string]any{}
for _, key := range []string{"colorMode", "fontSize", "contentWidth", "textFont", "codeFont", "lineHeight", "paragraphSpacing", "glassOpacity"} {
for _, key := range []string{"colorMode", "uiFontSize", "fontSize", "contentWidth", "textFont", "codeFont", "lineHeight", "paragraphSpacing", "glassOpacity"} {
if value, ok := settings[key]; ok {
out[key] = value
}
+2 -2
View File
@@ -43,7 +43,7 @@ func TestThemeAndShareAppearanceBoundaries(t *testing.T) {
t.Fatal("未读取到主题 CSS")
}
f.write("笔记.md", "正文")
f.do(f.admin, "PATCH", "/api/v1/settings", `{"theme":"paper","colorMode":"dark","fontSize":18,"privateSetting":"不公开","cssSnippets":[{"id":"a","name":"启用","css":"body { color: red }","enabled":true},{"id":"b","name":"停用","css":"不公开片段","enabled":false}]}`, nil)
f.do(f.admin, "PATCH", "/api/v1/settings", `{"theme":"paper","colorMode":"dark","fontSize":18,"uiFontSize":20,"privateSetting":"不公开","cssSnippets":[{"id":"a","name":"启用","css":"body { color: red }","enabled":true},{"id":"b","name":"停用","css":"不公开片段","enabled":false}]}`, nil)
sh := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", `{"path":"笔记.md","password":"访问密码"}`, nil))
base := "/api/v1/public/shares/" + sh.Token
if code := f.status(guest, "GET", base+"/appearance", "", nil); code != 401 {
@@ -59,7 +59,7 @@ func TestThemeAndShareAppearanceBoundaries(t *testing.T) {
if err := json.Unmarshal(raw, &appearance); err != nil {
t.Fatal(err)
}
if res.StatusCode != 200 || appearance["colorMode"] != "dark" || !strings.Contains(appearance["themeCSS"].(string), "#faf6ed") {
if res.StatusCode != 200 || appearance["colorMode"] != "dark" || appearance["uiFontSize"] != float64(20) || appearance["fontSize"] != float64(18) || !strings.Contains(appearance["themeCSS"].(string), "#faf6ed") {
t.Fatalf("分享外观错误:%s", raw)
}
if strings.Contains(string(raw), "不公开") || len(appearance["cssSnippets"].([]any)) != 1 {
+2 -2
View File
@@ -19,8 +19,8 @@ import (
"rawmark/internal/vault"
)
// 修改表结构时递增,旧索引会被丢弃重建
const schemaVersion = 2
// 表结构或派生元数据的解析规则变化时递增,旧索引会被丢弃重建。
const schemaVersion = 3
const schema = `
CREATE TABLE docs (
+19
View File
@@ -205,3 +205,22 @@ func TestMetadataTracksChangesAndRebuild(t *testing.T) {
t.Fatalf("索引恢复错误:%+v %v", notes, err)
}
}
func TestRebuildsLinkedImageMetadataFromOldIndex(t *testing.T) {
f := newFixture(t)
f.write("a.md", "[![预览](assets/used.png)](https://example.com)")
f.sync()
// 模拟旧版索引漏记图片引用;笔记正文和 rev 均不改变。
if _, err := f.idx.db.Exec(`UPDATE docs SET metadata = ?`, `{"title":"a","tags":[],"links":[],"assets":[],"warnings":[]}`); err != nil {
t.Fatal(err)
}
if _, err := f.idx.db.Exec(`PRAGMA user_version = 2`); err != nil {
t.Fatal(err)
}
f.reopen()
f.sync()
notes, ready, err := f.idx.Metadata()
if err != nil || !ready || len(notes) != 1 || len(notes[0].Assets) != 1 || notes[0].Assets[0].Target != "assets/used.png" {
t.Fatalf("旧索引未重新提取图片引用:%+v %v", notes, err)
}
}
+30
View File
@@ -66,3 +66,33 @@ func TestFolderUploadReusesDeduplication(t *testing.T) {
t.Fatalf("目标目录不存在应报错:%v", err)
}
}
func TestCleanupProtectsLinkedImages(t *testing.T) {
for _, style := range []string{"内联链接", "引用式链接"} {
t.Run(style, func(t *testing.T) {
v := newVault(t)
link, err := v.SaveFolderAsset("", "preview.png", strings.NewReader("image"), 100)
if err != nil {
t.Fatal(err)
}
content := "[![预览](" + link + ")](https://example.com)"
if style == "引用式链接" {
content = "[![预览][image]][site]\n\n[image]: " + link + "\n[site]: https://example.com\n"
}
if _, err := v.Write("a.md", []byte(content), Precondition{}); err != nil {
t.Fatal(err)
}
metadata := Analyze("a.md", []byte(content))
if len(metadata.Assets) != 1 || metadata.Assets[0].Target != link {
t.Fatalf("链接内图片未记录引用:%+v", metadata)
}
result, err := v.CleanupImages([]string{link}, func() ([]string, error) { return nil, nil })
if err != nil || len(result.Deleted) != 0 || len(result.Skipped) != 1 || result.Skipped[0].Reason != "仍被笔记引用" {
t.Fatalf("链接内图片未受保护:%+v %v", result, err)
}
if _, err := v.Stat(link); err != nil {
t.Fatal("正文引用的图片不应被移走", err)
}
})
}
}
+1 -1
View File
@@ -132,7 +132,7 @@ func Analyze(note string, content []byte) Metadata {
return ast.WalkSkipChildren, nil
case *ast.Link:
addRef(node.Destination.Str(source), node.Pos())
return ast.WalkSkipChildren, nil
// 链接文字中可以嵌套图片,仍须记录其资源引用。
case *ast.Image:
addRef(node.Destination.Str(source), node.Pos())
return ast.WalkSkipChildren, nil
+24
View File
@@ -93,3 +93,27 @@ func TestRenamedAssetsPreserveMathExamples(t *testing.T) {
t.Fatalf("公式示例被修改:%s", got)
}
}
func TestMovePreservesEntityShapedFilenames(t *testing.T) {
v := newVault(t)
for p, body := range map[string]string{"source.md": "[目标](target.md)", "target.md": "正文"} {
if _, err := v.Write(p, []byte(body), Precondition{}); err != nil {
t.Fatal(err)
}
}
const target = "a©.md"
if _, err := v.Move("target.md", target, nil); err != nil {
t.Fatal(err)
}
body, _, err := v.Read("source.md")
if err != nil {
t.Fatal(err)
}
metadata := Analyze("source.md", body)
if string(body) != "[目标](a%26copy;.md)" || len(metadata.Links) != 1 || metadata.Links[0].Target != target {
t.Fatalf("改名后的链接指向错误:%s %+v", body, metadata.Links)
}
if _, _, err := v.Read(metadata.Links[0].Target); err != nil {
t.Fatal("改写后的链接无法打开", err)
}
}
+1 -1
View File
@@ -785,4 +785,4 @@ func EscapeLinkPath(s string) string {
return linkEscaper.Replace(s)
}
var linkEscaper = strings.NewReplacer("%", "%25", "#", "%23", "?", "%3F", " ", "%20", "(", "%28", ")", "%29", "<", "%3C", ">", "%3E")
var linkEscaper = strings.NewReplacer("%", "%25", "&", "%26", "#", "%23", "?", "%3F", " ", "%20", "(", "%28", ")", "%29", "<", "%3C", ">", "%3E")
+3 -2
View File
@@ -44,8 +44,9 @@ defineProps<{ name: IconName; size?: number }>()
<template>
<svg
:width="size ?? 16"
:height="size ?? 16"
:width="size"
:height="size"
:class="{ 'rm-icon': size === undefined }"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
+5 -3
View File
@@ -116,16 +116,18 @@ async function trash() {
<style scoped>
.rm-image-dialog { max-width: 860px; width: min(860px, calc(100vw - 24px)); }
.rm-image-body { display: flex; flex-direction: column; gap: 14px; padding: 0 20px 20px; overflow-y: auto; font-size: 14px; }
.rm-image-body { display: flex; flex-direction: column; gap: 14px; padding: 0 20px 20px; overflow-y: auto; font-size: var(--rm-ui-font-size); }
.rm-image-body p { margin: 0; }
.rm-image-path { overflow-wrap: anywhere; color: var(--rm-text-muted); }
.rm-image-preview { display: flex; align-items: center; justify-content: center; min-height: 140px; background: var(--rm-bg); border: 1px solid var(--rm-divider); border-radius: 6px; padding: 12px; }
.rm-image-preview img { display: block; max-width: 100%; max-height: 44dvh; object-fit: contain; }
.rm-image-navigation, .rm-image-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.rm-image-navigation span { font-size: 12px; color: var(--rm-text-muted); text-align: center; }
.rm-image-actions { flex-wrap: wrap; }
.rm-image-navigation span { font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); text-align: center; }
.rm-image-navigation button, .rm-image-rename button { white-space: nowrap; }
.rm-image-rename { display: flex; flex-direction: column; gap: 8px; }
.rm-image-rename > div { display: flex; align-items: center; gap: 8px; }
.rm-image-rename input { min-width: 0; flex: 1; width: 0; border: 1px solid var(--rm-divider); background: var(--rm-bg); color: var(--rm-text); padding: 8px; border-radius: 6px; font: inherit; }
.rm-image-hint { color: var(--rm-text-muted); font-size: 12px; line-height: 1.6; }
.rm-image-hint { color: var(--rm-text-muted); font-size: var(--rm-ui-font-small); line-height: 1.6; }
@media(max-width:767px), (pointer:coarse) { .rm-image-rename input { font-size: max(16px, var(--rm-ui-font-size)); } }
</style>
+11 -10
View File
@@ -109,16 +109,17 @@ async function clean() {
</template>
<style scoped>
.rm-media-library { display: flex; flex-direction: column; min-height: 0; flex: 1; padding: 28px; overflow: hidden; }
.rm-media-header { display: flex; align-items: center; justify-content: space-between; padding: 0 0 24px; gap: 12px; } .rm-media-header small { font-size: 9px; color: var(--rm-accent); letter-spacing: .14em; } h1 { font: 600 27px var(--rm-font-heading); margin: 10px 0; } p { font-size: 11px; color: var(--rm-text-muted); line-height: 1.7; margin: 6px 0; }
.rm-media-header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; padding: 0 0 24px; gap: 12px; } .rm-media-header small { font-size: var(--rm-ui-font-caption); color: var(--rm-accent); letter-spacing: .14em; } h1 { font: 600 var(--rm-ui-font-title) var(--rm-font-heading); margin: 10px 0; } p { font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); line-height: 1.7; margin: 6px 0; }
.rm-media-workspace { display: flex; flex: 1; min-height: 0; border: 1px solid var(--rm-divider); border-radius: 16px; overflow: hidden; background: color-mix(in srgb,var(--rm-bg) calc(var(--rm-glass-opacity) * 100%),transparent); }
.rm-media-filters { flex: none; width: 155px; padding: 20px 12px; border-right: 1px solid var(--rm-divider); background: var(--rm-sidebar-bg); overflow: auto; } .rm-media-section-label { display: block; margin: 12px 8px; font-size: 9px; color: var(--rm-text-muted); } .rm-media-filters button { display: flex; align-items: center; gap: 8px; width: 100%; padding: 10px 8px; margin: 4px 0; background: none; border: 0; border-radius: 7px; font-size: 11px; text-align: left; cursor: pointer; } button:disabled { opacity: .5; cursor: default; } .rm-media-filters button.on { background: var(--rm-active); color: var(--rm-accent); } .rm-media-filters p { font-size: 9px; padding: 8px; }
.rm-media-content { flex: 1; min-width: 0; overflow: auto; padding: 20px; } .rm-media-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .rm-media-search { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 130px; padding: 8px 10px; border: 1px solid var(--rm-divider); border-radius: 8px; } .rm-media-search input { width: 100%; min-width: 0; background: none; color: var(--rm-text); border: 0; outline: none; font: 11px var(--rm-font-text); } select { max-width: 100%; padding: 8px; border: 1px solid var(--rm-divider); border-radius: 7px; background: var(--rm-bg); color: var(--rm-text); font: 10px var(--rm-font-text); }
.rm-media-upload { display: flex; justify-content: space-between; gap: 10px; align-items: center; margin: 16px 0; } .rm-media-upload label { display: flex; align-items: center; gap: 8px; min-width: 0; font-size: 10px; } .rm-media-upload label select { min-width: 0; flex: 1; } .rm-media-upload .rm-btn { font-size: 10px; }
.rm-media-grid { display: grid; grid-template-columns: repeat(auto-fill,minmax(145px,1fr)); gap: 16px; padding: 8px 0; } .rm-media-card { display: flex; flex-direction: column; gap: 9px; border: 1px solid var(--rm-divider); border-radius: 12px; padding: 10px; background: var(--rm-active); text-align: left; min-width: 0; cursor: pointer; } .rm-media-card:hover, .rm-media-card.selected { border-color: var(--rm-accent); box-shadow: 0 3px 12px var(--rm-selection); } .rm-media-thumbnail { display: grid; place-items: center; height: 130px; width: 100%; border-radius: 7px; overflow: hidden; background: var(--rm-sidebar-bg); } .rm-media-thumbnail img { width: 100%; height: 100%; object-fit: contain; } .rm-media-card strong { font-size: 10px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .rm-media-card small { font-size: 8px; color: var(--rm-text-muted); } .rm-media-grid.list { grid-template-columns: 1fr; gap: 6px; } .list .rm-media-card { flex-direction: row; align-items: center; gap: 12px; } .list .rm-media-thumbnail { width: 54px; height: 42px; flex: none; } .list .rm-media-card small { margin-left: auto; white-space: nowrap; }
.rm-media-inspector { flex: none; width: 230px; padding: 20px; border-left: 1px solid var(--rm-divider); overflow: auto; } .rm-media-deselect { margin-left: auto; display: flex; } .rm-media-detail-image { height: 145px; border-radius: 8px; background: var(--rm-sidebar-bg); display: grid; place-items: center; overflow: hidden; margin: 12px 0; } .rm-media-detail-image img { width: 100%; height: 100%; object-fit: contain; } h2 { font-size: 13px; overflow-wrap: anywhere; } .rm-media-path { font-size: 9px; overflow-wrap: anywhere; } dl { display: grid; grid-template-columns: 65px 1fr; gap: 12px; font-size: 10px; margin: 20px 0; } dt { color: var(--rm-text-muted); } dd { margin: 0; } .rm-media-inspector > .rm-btn { width: 100%; justify-content: center; } h3 { font-size: 11px; border-top: 1px solid var(--rm-divider); margin-top: 24px; padding-top: 20px; } .rm-media-reference { display: flex; gap: 8px; text-align: left; background: none; border: 0; padding: 10px 0; width: 100%; cursor: pointer; font-size: 11px; } .rm-media-reference small { display: block; color: var(--rm-text-muted); font-size: 9px; margin-top: 5px; overflow-wrap: anywhere; }
.rm-media-warning, .rm-media-report { padding: 12px; border-radius: 8px; background: var(--rm-warn-bg); color: var(--rm-warn-text); font-size: 11px; } .rm-media-warning .rm-btn { font-size: 10px; margin-left: 8px; } .rm-media-report p { color: inherit; overflow-wrap: anywhere; }
.rm-cleanup-dialog { width: min(640px,calc(100vw - 24px)); } .rm-cleanup-dialog ul { list-style: none; overflow: auto; padding: 12px 20px; margin: 0; } .rm-cleanup-dialog li { padding: 10px 0; font-size: 12px; overflow-wrap: anywhere; } .rm-cleanup-dialog label { display: flex; gap: 10px; } .rm-cleanup-dialog small { margin-left: auto; color: var(--rm-text-muted); white-space: nowrap; } .rm-cleanup-actions { display: flex; justify-content: flex-end; gap: 12px; padding: 20px; }
.rm-media-filters { flex: none; width: 190px; padding: 20px 12px; border-right: 1px solid var(--rm-divider); background: var(--rm-sidebar-bg); overflow: auto; } .rm-media-section-label { display: block; margin: 12px 8px; font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); } .rm-media-filters button { overflow-wrap: anywhere; display: flex; align-items: center; gap: 8px; width: 100%; padding: 10px 8px; margin: 4px 0; background: none; border: 0; border-radius: 7px; font-size: var(--rm-ui-font-size); text-align: left; cursor: pointer; } button:disabled { opacity: .5; cursor: default; } .rm-media-filters button.on { background: var(--rm-active); color: var(--rm-accent); } .rm-media-filters p { font-size: var(--rm-ui-font-caption); padding: 8px; }
.rm-media-content { flex: 1; min-width: 0; overflow: auto; padding: 20px; } .rm-media-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .rm-media-search { box-sizing: border-box; min-height: var(--rm-control-size); display: flex; align-items: center; gap: 8px; flex: 1; min-width: 130px; padding: 8px 10px; border: 1px solid var(--rm-divider); border-radius: 8px; } .rm-media-search input { width: 100%; min-width: 0; background: none; color: var(--rm-text); border: 0; outline: none; font: var(--rm-ui-font-size) var(--rm-font-text); } select { min-height: var(--rm-control-size); max-width: 100%; padding: 8px; border: 1px solid var(--rm-divider); border-radius: 7px; background: var(--rm-bg); color: var(--rm-text); font: var(--rm-ui-font-size) var(--rm-font-text); }
.rm-media-upload { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 10px; align-items: center; margin: 16px 0; } .rm-media-upload label { display: flex; align-items: center; gap: 8px; min-width: 0; font-size: var(--rm-ui-font-size); } .rm-media-upload label select { min-height: var(--rm-control-size); max-width: 100%; min-width: 0; flex: 1; } .rm-media-upload .rm-btn { font-size: var(--rm-ui-font-size); }
.rm-media-grid { display: grid; grid-template-columns: repeat(auto-fill,minmax(145px,1fr)); gap: 16px; padding: 8px 0; } .rm-media-card { display: flex; flex-direction: column; gap: 9px; border: 1px solid var(--rm-divider); border-radius: 12px; padding: 10px; background: var(--rm-active); text-align: left; min-width: 0; cursor: pointer; } .rm-media-card:hover, .rm-media-card.selected { border-color: var(--rm-accent); box-shadow: 0 3px 12px var(--rm-selection); } .rm-media-thumbnail { display: grid; place-items: center; height: 130px; width: 100%; border-radius: 7px; overflow: hidden; background: var(--rm-sidebar-bg); } .rm-media-thumbnail img { width: 100%; height: 100%; object-fit: contain; } .rm-media-card strong { max-width: 100%; font-size: var(--rm-ui-font-size); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .rm-media-card small { font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); } .rm-media-grid.list { grid-template-columns: 1fr; gap: 6px; } .list .rm-media-card { flex-direction: row; flex-wrap: wrap; align-items: center; gap: 12px; } .list .rm-media-thumbnail { width: 54px; height: 42px; flex: none; } .list .rm-media-card small { margin-left: auto; white-space: nowrap; }
.rm-media-inspector { flex: none; width: 230px; padding: 20px; border-left: 1px solid var(--rm-divider); overflow: auto; } .rm-media-deselect { margin-left: auto; display: flex; } .rm-media-detail-image { height: 145px; border-radius: 8px; background: var(--rm-sidebar-bg); display: grid; place-items: center; overflow: hidden; margin: 12px 0; } .rm-media-detail-image img { width: 100%; height: 100%; object-fit: contain; } h2 { font-size: var(--rm-ui-font-size); overflow-wrap: anywhere; } .rm-media-path { font-size: var(--rm-ui-font-caption); overflow-wrap: anywhere; } dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 12px; font-size: var(--rm-ui-font-caption); margin: 20px 0; } dt { color: var(--rm-text-muted); } dd { margin: 0; overflow-wrap: anywhere; } .rm-media-inspector > .rm-btn { width: 100%; justify-content: center; } h3 { font-size: var(--rm-ui-font-size); border-top: 1px solid var(--rm-divider); margin-top: 24px; padding-top: 20px; } .rm-media-reference { overflow-wrap: anywhere; display: flex; gap: 8px; text-align: left; background: none; border: 0; padding: 10px 0; width: 100%; cursor: pointer; font-size: var(--rm-ui-font-size); } .rm-media-reference small { display: block; color: var(--rm-text-muted); font-size: var(--rm-ui-font-caption); margin-top: 5px; overflow-wrap: anywhere; }
.rm-media-warning, .rm-media-report { padding: 12px; border-radius: 8px; background: var(--rm-warn-bg); color: var(--rm-warn-text); font-size: var(--rm-ui-font-small); } .rm-media-warning .rm-btn { font-size: var(--rm-ui-font-size); margin-left: 8px; } .rm-media-report p { color: inherit; overflow-wrap: anywhere; }
.rm-cleanup-dialog { width: min(640px,calc(100vw - 24px)); } .rm-cleanup-dialog ul { list-style: none; overflow: auto; padding: 12px 20px; margin: 0; } .rm-cleanup-dialog li { padding: 10px 0; font-size: var(--rm-ui-font-small); overflow-wrap: anywhere; } .rm-cleanup-dialog label { display: flex; gap: 10px; } .rm-cleanup-dialog small { margin-left: auto; color: var(--rm-text-muted); white-space: nowrap; } .rm-cleanup-actions { display: flex; justify-content: flex-end; gap: 12px; padding: 20px; }
.rm-media-mobile-filters { display: none; }
@media(max-width:1199px) { .rm-media-library { padding: 20px; } .rm-media-filters { width: 130px; padding: 12px 8px; } .rm-media-inspector { width: 180px; padding: 14px; } }
@media(max-width:767px) { .rm-media-mobile-filters { display: flex; gap: 6px; margin-bottom: 12px; } .rm-media-mobile-filters .rm-btn { font-size: 10px; } .rm-media-library { padding: 18px 12px; } h1 { font-size: 22px; } .rm-media-header { padding-bottom: 16px; } .rm-media-filters { display: none; } .rm-media-content { padding: 12px; } .rm-media-toolbar { gap: 6px; } .rm-media-inspector { position: absolute; inset: 0; z-index: 20; background: var(--rm-bg); width: auto; padding: 20px; } .rm-media-workspace { position: relative; } .rm-media-search { flex-basis: 100%; } .rm-media-grid { grid-template-columns: repeat(auto-fill,minmax(120px,1fr)); gap: 10px; } .rm-media-upload { flex-wrap: wrap; } .rm-media-upload label { flex: 1; } .rm-media-upload select { max-width: 180px; } }
@media(max-width:1199px) { .rm-media-library { padding: 20px; } .rm-media-filters { width: 180px; padding: 12px 8px; } .rm-media-inspector { width: 180px; padding: 14px; } }
@media(max-width:767px) { .rm-media-mobile-filters { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; } .rm-media-mobile-filters .rm-btn { font-size: var(--rm-ui-font-size); } .rm-media-library { padding: 18px 12px; } h1 { font-size: var(--rm-ui-font-heading); } .rm-media-header { padding-bottom: 16px; } .rm-media-filters { display: none; } .rm-media-content { padding: 12px; } .rm-media-toolbar { gap: 6px; } .rm-media-inspector { position: absolute; inset: 0; z-index: 20; background: var(--rm-bg); width: auto; padding: 20px; } .rm-media-workspace { position: relative; } .rm-media-search { flex-basis: 100%; } .rm-media-grid { grid-template-columns: repeat(auto-fill,minmax(120px,1fr)); gap: 10px; } .rm-media-upload { flex-wrap: wrap; } .rm-media-upload label { flex: 1; } .rm-media-upload select { min-height: var(--rm-control-size); max-width: 180px; } }
@media(max-width:767px), (pointer:coarse) { input[type=search], select { font-size: max(16px, var(--rm-ui-font-size)); } }
</style>
+3 -2
View File
@@ -102,13 +102,14 @@ async function submit() {
</template>
<style scoped>
.rm-import-body { display: flex; flex-direction: column; gap: 16px; padding: 8px 20px 20px; overflow-y: auto; font-size: 14px; }
.rm-import-body { display: flex; flex-direction: column; gap: 16px; padding: 8px 20px 20px; overflow-y: auto; font-size: var(--rm-ui-font-size); }
.rm-import-body p { margin: 0; }
.rm-import-body label { display: flex; flex-direction: column; gap: 8px; overflow-wrap: anywhere; }
.rm-import-body input { min-width: 0; padding: 8px; border: 1px solid var(--rm-divider); border-radius: 6px; background: var(--rm-bg); color: var(--rm-text); font: inherit; }
.rm-import-target, .rm-import-hint { color: var(--rm-text-muted); overflow-wrap: anywhere; }
.rm-import-hint { font-size: 12px; line-height: 1.6; }
.rm-import-hint { font-size: var(--rm-ui-font-small); line-height: 1.6; }
.rm-import-files { display: flex; flex-direction: column; gap: 12px; }
.rm-import-actions { display: flex; justify-content: flex-end; gap: 8px; }
.rm-import-warnings { line-height: 1.6; overflow-wrap: anywhere; }
@media(max-width:767px), (pointer:coarse) { .rm-import-body input { font-size: max(16px, var(--rm-ui-font-size)); } }
</style>
+7 -6
View File
@@ -75,10 +75,11 @@ watch(() => ws.view, () => { query.value = ''; tag.value = '' })
</template>
<style scoped>
.rm-knowledge-panel { flex: 1; overflow: auto; padding: 36px 40px; }
.rm-knowledge-heading { display: flex; justify-content: space-between; align-items: center; gap: 20px; } .rm-eyebrow { font-size: 9px; letter-spacing: .15em; color: var(--rm-accent); } h1 { font-family: var(--rm-font-heading); font-size: 28px; font-weight: 600; margin: 12px 0; } p { font-size: 12px; color: var(--rm-text-muted); }
.rm-knowledge-tools { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: 28px 0 20px; } label { display: flex; align-items: center; gap: 8px; font-size: 11px; } input[type=search], select { border: 1px solid var(--rm-divider); padding: 10px 12px; border-radius: 8px; color: var(--rm-text); background: var(--rm-active); font: inherit; font-size: 12px; }
.rm-graph-canvas { position: relative; border: 1px solid var(--rm-divider); border-radius: 16px; background: radial-gradient(var(--rm-divider) 1px, transparent 1px) 0 0/20px 20px; overflow: hidden; } .rm-graph-canvas > svg { width: 100%; height: min(55vh,600px); display: block; touch-action: none; cursor: grab; } line { stroke: var(--rm-divider); stroke-width: 1.5; } circle { fill: var(--rm-accent); opacity: .6; } .current circle { opacity: 1; stroke: var(--rm-selection); stroke-width: 10px; } g { cursor: pointer; } g:hover circle, g:focus circle { opacity: 1; } text { fill: var(--rm-text-muted); font-family: var(--rm-font-text); font-size: 10px; } .rm-graph-controls { display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 10px; background: var(--rm-active); } .rm-graph-controls span { margin-right: auto; font-size: 10px; color: var(--rm-text-muted); } .rm-graph-empty { position: absolute; inset: 40% 0 auto; text-align: center; pointer-events: none; }
.rm-missing-links { margin-top: 20px; font-size: 12px; color: var(--rm-text-muted); } .rm-missing-links button { display: block; padding: 8px; background: none; border: 0; cursor: pointer; text-align: left; }
.rm-tags-cloud { display: flex; flex-wrap: wrap; gap: 10px; margin: 24px 0; } .rm-tags-cloud button { padding: 8px 12px; border: 1px solid var(--rm-divider); border-radius: 7px; background: var(--rm-active); font-size: 12px; cursor: pointer; } .rm-tags-cloud .on { background: var(--rm-selection); color: var(--rm-accent); } .rm-tag-results > button { display: flex; align-items: center; gap: 16px; width: 100%; padding: 18px 12px; border: 0; border-bottom: 1px solid var(--rm-divider); text-align: left; background: none; cursor: pointer; } strong { font-size: 13px; font-weight: 500; } small { display: block; margin-top: 5px; font-size: 10px; color: var(--rm-text-muted); } .rm-tag-results span { margin-left: auto; font-size: 10px; color: var(--rm-accent); } .rm-tag-results > button:hover { background: var(--rm-hover); }
@media(max-width:767px) { .rm-knowledge-panel { padding: 24px 16px; } h1 { font-size: 23px; } .rm-knowledge-heading { align-items: flex-start; } .rm-knowledge-heading .rm-btn { font-size: 10px; } .rm-knowledge-tools input[type=search] { width: 140px; } .rm-tag-results span { display: none; } }
.rm-knowledge-heading { display: flex; justify-content: space-between; align-items: center; gap: 20px; } .rm-eyebrow { font-size: var(--rm-ui-font-caption); letter-spacing: .15em; color: var(--rm-accent); } h1 { font-family: var(--rm-font-heading); font-size: var(--rm-ui-font-title); font-weight: 600; margin: 12px 0; } p { font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); }
.rm-knowledge-tools { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: 28px 0 20px; } label { display: flex; align-items: center; gap: 8px; font-size: var(--rm-ui-font-size); } input[type=search], select { border: 1px solid var(--rm-divider); padding: 10px 12px; border-radius: 8px; color: var(--rm-text); background: var(--rm-active); font: inherit; font-size: var(--rm-ui-font-size); }
.rm-graph-canvas { position: relative; border: 1px solid var(--rm-divider); border-radius: 16px; background: radial-gradient(var(--rm-divider) 1px, transparent 1px) 0 0/20px 20px; overflow: hidden; } .rm-graph-canvas > svg { width: 100%; height: min(55vh,600px); display: block; touch-action: none; cursor: grab; } line { stroke: var(--rm-divider); stroke-width: 1.5; } circle { fill: var(--rm-accent); opacity: .6; } .current circle { opacity: 1; stroke: var(--rm-selection); stroke-width: 10px; } g { cursor: pointer; } g:hover circle, g:focus circle { opacity: 1; } text { fill: var(--rm-text-muted); font-family: var(--rm-font-text); font-size: var(--rm-ui-font-caption); } .rm-graph-controls { display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 10px; background: var(--rm-active); } .rm-graph-controls span { margin-right: auto; font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); } .rm-graph-empty { position: absolute; inset: 40% 0 auto; text-align: center; pointer-events: none; }
.rm-missing-links { margin-top: 20px; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); } .rm-missing-links button { display: block; padding: 8px; background: none; border: 0; cursor: pointer; text-align: left; }
.rm-tags-cloud { display: flex; flex-wrap: wrap; gap: 10px; margin: 24px 0; } .rm-tags-cloud button { padding: 8px 12px; border: 1px solid var(--rm-divider); border-radius: 7px; background: var(--rm-active); font-size: var(--rm-ui-font-size); cursor: pointer; } .rm-tags-cloud .on { background: var(--rm-selection); color: var(--rm-accent); } .rm-tag-results > button { display: flex; align-items: center; gap: 16px; width: 100%; padding: 18px 12px; border: 0; border-bottom: 1px solid var(--rm-divider); text-align: left; background: none; cursor: pointer; } strong { font-size: var(--rm-ui-font-size); font-weight: 500; } small { display: block; margin-top: 5px; font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); } .rm-tag-results span { margin-left: auto; font-size: var(--rm-ui-font-caption); color: var(--rm-accent); } .rm-tag-results > button:hover { background: var(--rm-hover); }
@media(max-width:767px) { .rm-knowledge-panel { padding: 24px 16px; } h1 { font-size: var(--rm-ui-font-heading); } .rm-knowledge-heading { align-items: flex-start; } .rm-knowledge-heading .rm-btn { font-size: var(--rm-ui-font-size); } .rm-knowledge-tools input[type=search] { width: 140px; } .rm-tag-results span { display: none; } }
@media(max-width:767px), (pointer:coarse) { input[type=search], select { font-size: max(16px, var(--rm-ui-font-size)); } }
</style>
+14 -5
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { indentLess, indentMore, redo, undo } from '@codemirror/commands'
import type { EditorView } from '@codemirror/view'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { cycleHeading, insertLink, toggleBlock, toggleBold, toggleCode, toggleItalic, toggleStrike, toggleInline } from '../editor/format'
import Icon from './Icon.vue'
import { settings } from './settings'
@@ -11,6 +11,7 @@ import { activeTab, onEditorUpdate, ws, editorView, insertImagesFromPicker } fro
// 按钮在 mousedown 时阻止默认行为,焦点留在编辑器中,键盘不会收起
// (不能在 pointerdown 上阻止:WebKit 会因此不再触发 click)
const visible = ref(false)
const floatingToolbar = ref<HTMLDivElement>()
const floating = ref<{ left: number; top: number } | null>(null)
let unsubscribe: (() => void) | undefined
const coarse = matchMedia('(pointer: coarse)')
@@ -45,7 +46,7 @@ function hideKeyboard() {
// 焦点从编辑器移到别处时,focusout 先于新元素获得焦点,等一帧再判断
function sync() {
requestAnimationFrame(() => {
requestAnimationFrame(async () => {
const v = editorView()
visible.value = !!ws.active && activeTab()?.status === 'ready' && (!coarse.matches || !!v?.hasFocus)
floating.value = null
@@ -54,7 +55,15 @@ function sync() {
const coords = v.coordsAtPos(selection.head)
const rect = v.scrollDOM.getBoundingClientRect()
if (!coords || coords.bottom < rect.top || coords.top > rect.bottom) return
floating.value = { left: Math.max(12, Math.min(innerWidth - 248, coords.left - 120)), top: coords.top - 48 > rect.top ? coords.top - 48 : coords.bottom + 8 }
floating.value = { left: 0, top: 0 }
await nextTick()
const toolbar = floatingToolbar.value
if (!toolbar || !floating.value) return
const { width, height } = toolbar.getBoundingClientRect()
floating.value = {
left: Math.max(12, Math.min(innerWidth - width - 12, coords.left - width / 2)),
top: Math.max(8, Math.min(innerHeight - height - 8, coords.top - height - 8 > rect.top ? coords.top - height - 8 : coords.bottom + 8)),
}
})
}
@@ -73,7 +82,7 @@ onBeforeUnmount(() => {
document.removeEventListener('focusin', sync)
document.removeEventListener('focusout', sync)
})
watch(() => [ws.active, activeTab()?.status, settings.selectionToolbar, ws.settings, ws.images], sync)
watch(() => [ws.active, activeTab()?.status, settings.selectionToolbar, settings.uiFontSize, ws.settings, ws.images], sync)
</script>
<template>
@@ -97,7 +106,7 @@ watch(() => [ws.active, activeTab()?.status, settings.selectionToolbar, ws.setti
<Icon name="chevron" class="down" />
</button>
</div>
<div v-if="floating && ws.view === 'notes'" class="rm-selection-toolbar" role="toolbar" aria-label="选中文本格式" :style="{ left: floating.left + 'px', top: floating.top + 'px' }">
<div ref="floatingToolbar" v-if="floating && ws.view === 'notes'" class="rm-selection-toolbar" role="toolbar" aria-label="选中文本格式" :style="{ left: floating.left + 'px', top: floating.top + 'px' }">
<button v-for="b in BUTTONS.slice(0, 5).concat(BUTTONS.filter(button => button.title === '行内代码' || button.title === '链接'))" :key="b.title" :title="b.title" :aria-label="b.title" :class="b.cls" @mousedown.prevent @click="run(b.run)">{{ b.label }}</button>
</div>
</template>
+4 -4
View File
@@ -18,9 +18,9 @@ const note = computed(() => metadata.notes.find(note => note.path === ws.active)
</template>
<style scoped>
.rm-note-relations { padding: 20px 16px; border-top: 1px solid var(--rm-divider); overflow: auto; max-height: 40%; }
h3 { font-size: 11px; margin: 0 0 12px; } h3 small { color: var(--rm-text-muted); margin-left: 6px; }
p { font-size: 10px; color: var(--rm-text-muted); line-height: 1.8; }
h3 { font-size: var(--rm-ui-font-size); margin: 0 0 12px; } h3 small { color: var(--rm-text-muted); margin-left: 6px; }
p { font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); line-height: 1.8; }
button { display: flex; flex-direction: column; gap: 5px; width: 100%; text-align: left; padding: 9px; border: 0; border-radius: 8px; background: none; cursor: pointer; }
button:hover { background: var(--rm-hover); } strong { font-size: 11px; font-weight: 500; } small { font-size: 9px; color: var(--rm-text-muted); overflow-wrap: anywhere; }
.rm-note-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 18px; } .rm-note-tags span { font-size: 9px; padding: 4px 7px; border-radius: 5px; background: var(--rm-selection); color: var(--rm-accent); }
button:hover { background: var(--rm-hover); } strong { font-size: var(--rm-ui-font-size); font-weight: 500; } small { font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); overflow-wrap: anywhere; }
.rm-note-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 18px; } .rm-note-tags span { font-size: var(--rm-ui-font-caption); padding: 4px 7px; border-radius: 5px; background: var(--rm-selection); color: var(--rm-accent); }
</style>
+23 -3
View File
@@ -4,7 +4,7 @@ import { api, ApiError } from './api'
import { runCommand } from './commands'
import Icon, { type IconName } from './Icon.vue'
import AppearanceSettings from './AppearanceSettings.vue'
import { CODE_FONTS, TEXT_FONTS } from '../themes/typography'
import { CODE_FONTS, TEXT_FONTS, UI_FONT_SIZE } from '../themes/typography'
import PluginsPanel from './PluginsPanel.vue'
import { installApp, installPrompt } from './pwa'
import { CONTENT_WIDTHS, TAB_SIZES, loadSettings, settings, updateSettings, type Settings } from './settings'
@@ -71,6 +71,17 @@ function changeFont(event: Event) {
if (value !== settings.fontSize) void change({ fontSize: value })
}
function changeUIFont(event: Event) {
const input = event.target as HTMLInputElement
const value = input.valueAsNumber
if (!Number.isInteger(value) || value < UI_FONT_SIZE.min || value > UI_FONT_SIZE.max) {
toast(`界面字号须为 ${UI_FONT_SIZE.min}–${UI_FONT_SIZE.max} 之间的整数`)
input.value = String(settings.uiFontSize)
return
}
if (value !== settings.uiFontSize) void change({ uiFontSize: value })
}
function changeFolder(key: 'dailyFolder' | 'templatesFolder', event: Event) {
const input = event.target as HTMLInputElement
const path = input.value.trim()
@@ -124,14 +135,23 @@ async function changePassword() {
<div class="rm-settings-content">
<PluginsPanel v-if="section === 'plugins'" />
<template v-else-if="section === 'appearance'">
<h3 class="rm-settings-group-title">界面显示</h3>
<div class="rm-setting">
<label for="rm-ui-font-size" class="rm-setting-label">界面字号</label>
<div class="rm-ui-font-control">
<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>
<button class="rm-btn" aria-label="重置界面字号" @click="change({ uiFontSize: UI_FONT_SIZE.default })">重置</button>
</div>
</div>
<p class="rm-share-note">调整菜单、侧栏与按钮文字,不影响正文和代码字号。</p>
<AppearanceSettings />
<div class="rm-setting"><label for="rm-glass-opacity" class="rm-setting-label">磨砂玻璃通透度</label><input id="rm-glass-opacity" type="range" min="0.4" max="1" step="0.05" :value="settings.glassOpacity" @change="change({ glassOpacity: Number(($event.target as HTMLInputElement).value) })" /><span>{{ Math.round(settings.glassOpacity * 100) }}%</span></div>
<h3 class="rm-settings-group-title">编辑与阅读排版</h3>
<div class="rm-setting"><label for="rm-text-font" class="rm-setting-label">正文主要字体</label><select id="rm-text-font" :value="settings.textFont" @change="change({ textFont: ($event.target as HTMLSelectElement).value as Settings['textFont'] })"><option v-for="(font, id) in TEXT_FONTS" :key="id" :value="id">{{ font.label }}</option></select></div>
<div class="rm-setting"><label for="rm-code-font" class="rm-setting-label">代码字体</label><select id="rm-code-font" :value="settings.codeFont" @change="change({ codeFont: ($event.target as HTMLSelectElement).value as Settings['codeFont'] })"><option v-for="(font, id) in CODE_FONTS" :key="id" :value="id">{{ font.label }}</option></select></div>
<div class="rm-setting">
<span class="rm-setting-label">字号</span>
<label class="rm-font-input"><input type="number" step="any" :value="settings.fontSize" aria-label="字号" @blur="changeFont" @keydown.enter="($event.target as HTMLInputElement).blur()" /> px</label>
<span class="rm-setting-label">编辑器字号</span>
<label class="rm-font-input"><input type="number" step="any" :value="settings.fontSize" aria-label="编辑器字号" @blur="changeFont" @keydown.enter="($event.target as HTMLInputElement).blur()" /> px</label>
</div>
<div class="rm-setting">
<span class="rm-setting-label">正文宽度</span>
+1 -1
View File
@@ -59,7 +59,7 @@ async function reset(command: Command) {
<div class="rm-shortcut-bindings">
<span v-for="(key, index) in commandKeys(command)" :key="key" class="rm-shortcut-chip">
<kbd>{{ formatKeys(key) }}</kbd>
<button class="rm-icon-btn" :disabled="busy" :aria-label="`移除${command.title}的${formatKeys(key)}`" @click="save({ ...settings.shortcuts, [command.id]: commandKeys(command).filter((_, i) => i !== index) })"><Icon name="close" :size="12" /></button>
<button class="rm-icon-btn" :disabled="busy" :aria-label="`移除${command.title}的${formatKeys(key)}`" @click="save({ ...settings.shortcuts, [command.id]: commandKeys(command).filter((_, i) => i !== index) })"><Icon name="close" :size="16" /></button>
</span>
<input v-if="recording === command.id" :ref="(el) => { recorder = el as HTMLInputElement }" data-shortcut-recorder readonly aria-label="按下快捷键" placeholder="按下快捷键…" @keydown.prevent.stop="capture($event, command)" @blur="recording = null" />
<button v-else class="rm-btn" :disabled="busy" @click="record(command.id)">添加</button>
+2 -2
View File
@@ -97,11 +97,11 @@ function closeRight(path: string) {
@drop="onDrop"
@dragend="onDragEnd"
>
<span v-if="tab.pinned" class="rm-tab-pin" title="已固定"><Icon name="pin" :size="12" /></span>
<span v-if="tab.pinned" class="rm-tab-pin" title="已固定"><Icon name="pin" :size="16" /></span>
<span class="rm-tab-title">{{ noteTitle(tab.path) }}</span>
<button class="rm-tab-close" :class="{ dirty: tab.dirty }" title="关闭" @click.stop="closeTab(tab.path)">
<span class="rm-dirty-dot" />
<Icon name="close" :size="12" />
<Icon name="close" :size="16" />
</button>
</div>
<button class="rm-icon-btn rm-tab-add" title="新建笔记" @click="createNote('')"><Icon name="plus" /></button>
+2 -1
View File
@@ -30,5 +30,6 @@ async function create() {
</div>
</template>
<style scoped>
.rm-template-dialog { width: min(520px, calc(100vw - 24px)); } form { display: grid; gap: 20px; padding: 20px; } label { display: grid; gap: 9px; font-size: 12px; } input, select { padding: 10px; border: 1px solid var(--rm-divider); border-radius: 8px; background: var(--rm-bg); color: var(--rm-text); font: inherit; } .rm-template-actions { display: flex; justify-content: space-between; gap: 12px; }
.rm-template-dialog { width: min(520px, calc(100vw - 24px)); overflow-y: auto; } form { display: grid; gap: 20px; padding: 20px; } label { display: grid; gap: 9px; font-size: var(--rm-ui-font-size); } input, select { padding: 10px; border: 1px solid var(--rm-divider); border-radius: 8px; background: var(--rm-bg); color: var(--rm-text); font: inherit; } .rm-template-actions { display: flex; justify-content: space-between; gap: 12px; }
@media(max-width:767px), (pointer:coarse) { input, select { font-size: max(16px, var(--rm-ui-font-size)); } }
</style>
+33 -7
View File
@@ -15,6 +15,9 @@ const isOpen = computed(() => expanded.has(props.node.path))
const isActive = computed(() => (image.value ? ws.image : ws.active) === props.node.path)
const isRenaming = computed(() => ws.renaming === props.node.path)
const menuOpen = ref(false)
const row = ref<HTMLElement>()
const menuElement = ref<HTMLElement>()
const menuPosition = ref({ left: 0, top: 0 })
const dropTarget = ref(false)
const input = ref<HTMLInputElement>()
const draft = ref('')
@@ -30,8 +33,7 @@ function onTouchStart(e: TouchEvent) {
pressStart = { x: e.touches[0].clientX, y: e.touches[0].clientY }
pressTimer = setTimeout(() => {
longPressed = true
contextSelection(props.node.path)
menuOpen.value = true
showMenu()
}, LONG_PRESS_MS)
}
@@ -98,6 +100,21 @@ function showMenu() {
menuOpen.value = true
}
watch(menuOpen, async (open, _previous, onCleanup) => {
if (!open) return
const close = () => { menuOpen.value = false }
window.addEventListener('resize', close)
onCleanup(() => window.removeEventListener('resize', close))
await nextTick()
if (!menuOpen.value || !row.value || !menuElement.value) return
const anchor = row.value.getBoundingClientRect()
const { width, height } = menuElement.value.getBoundingClientRect()
menuPosition.value = {
left: Math.max(4, Math.min(anchor.right - width, innerWidth - width - 4)),
top: Math.max(4, Math.min(anchor.bottom, innerHeight - height - 4)),
}
})
function cancelRename() {
ws.renaming = null
}
@@ -129,6 +146,7 @@ function onDrop(e: DragEvent) {
<template>
<li class="rm-tree-item">
<div
ref="row"
class="rm-tree-row"
:class="{ active: isActive, selected: selectedPaths.has(node.path), drop: dropTarget, 'menu-open': menuOpen }"
:style="{ paddingLeft: depth * 14 + 8 + 'px' }"
@@ -146,7 +164,7 @@ function onDrop(e: DragEvent) {
@dragleave="dropTarget = false"
@drop="onDrop"
>
<span class="rm-tree-chevron" :class="{ open: isOpen, hidden: !node.dir }"><Icon name="chevron" :size="12" /></span>
<span class="rm-tree-chevron" :class="{ open: isOpen, hidden: !node.dir }"><Icon name="chevron" :size="16" /></span>
<input
v-if="isRenaming"
ref="input"
@@ -157,12 +175,15 @@ function onDrop(e: DragEvent) {
@keydown.esc.prevent="cancelRename"
@blur="commitRename"
/>
<Icon v-if="image" name="image" :size="14" />
<Icon v-if="image" name="image" :size="18" />
<span v-if="!isRenaming" class="rm-tree-name">{{ node.dir ? node.name : node.name.replace(/\.md$/, '') }}</span>
<button class="rm-icon-btn rm-tree-more" title="更多操作" @click.stop="menuOpen ? (menuOpen = false) : showMenu()">
<Icon name="more" />
</button>
<div v-if="menuOpen" class="rm-menu" @click.stop>
</div>
<Teleport to="body">
<div v-if="menuOpen" class="rm-menu-backdrop rm-tree-menu-backdrop" @click.stop="menuOpen = false" @contextmenu.prevent.stop="menuOpen = false" />
<div v-if="menuOpen" ref="menuElement" class="rm-menu rm-tree-menu" :style="{ left: menuPosition.left + 'px', top: menuPosition.top + 'px' }" @click.stop>
<template v-if="node.dir && !assetDir && selectedPaths.size === 1">
<button @click="runMenu('note')"><Icon name="noteAdd" />新建笔记</button>
<button @click="runMenu('folder')"><Icon name="folderAdd" />新建文件夹</button>
@@ -176,10 +197,15 @@ function onDrop(e: DragEvent) {
<button @click="runMenu('export')"><Icon name="note" />导出 Markdown</button>
<button v-if="!assetDir" class="danger" @click="runMenu('delete')"><Icon name="trash" />删除</button>
</div>
<div v-if="menuOpen" class="rm-menu-backdrop" @click.stop="menuOpen = false" @contextmenu.prevent.stop="menuOpen = false" />
</div>
</Teleport>
<ul v-if="node.dir && isOpen" class="rm-tree-list">
<TreeNode v-for="child in node.children" :key="child.path" :node="child" :depth="depth + 1" />
</ul>
</li>
</template>
<style scoped>
.rm-tree-menu { position: fixed; right: auto; z-index: 45; width: max-content; min-width: 0; max-width: calc(100vw - 8px); max-height: calc(100dvh - 8px); box-sizing: border-box; overflow: auto; }
.rm-tree-menu button { flex: none; white-space: nowrap; }
.rm-tree-menu-backdrop { z-index: 44; }
</style>
+162 -139
View File
@@ -9,13 +9,15 @@ body {
display: flex;
flex-direction: column;
}
.rm-app-status { display: flex; align-items: center; justify-content: center; gap: 12px; padding: 6px 12px; padding-top: max(6px, env(safe-area-inset-top)); background: var(--rm-warn-bg); color: var(--rm-warn-text); font-size: 13px; flex: none; }
.rm-app-status { display: flex; align-items: center; justify-content: center; gap: 12px; padding: 6px 12px; padding-top: max(6px, env(safe-area-inset-top)); background: var(--rm-warn-bg); color: var(--rm-warn-text); font-size: var(--rm-ui-font-small); flex: none; }
#app > .rm-shell, #app > .rm-splash, #app > .rm-auth { flex: 1; min-height: 0; height: auto; }
@media (pointer: coarse) { .rm-app-status .rm-btn { min-height: 44px; } }
@media (pointer: coarse) { .rm-app-status .rm-btn { min-height: var(--rm-control-size); } }
body {
overflow: hidden;
-webkit-tap-highlight-color: transparent;
}
input, select, textarea { font-size: var(--rm-ui-font-size); }
input:not([type=checkbox],[type=radio],[type=range],[type=file]), select { box-sizing: border-box; min-height: var(--rm-control-size); }
button {
font: inherit;
color: inherit;
@@ -26,12 +28,12 @@ button {
display: inline-flex;
align-items: center;
gap: 6px;
height: 30px;
height: var(--rm-control-size);
padding: 0 12px;
border: 1px solid var(--rm-divider);
border-radius: 6px;
background: var(--rm-bg);
font-size: 13px;
font-size: var(--rm-ui-font-size);
cursor: pointer;
white-space: nowrap;
}
@@ -44,8 +46,8 @@ button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
width: var(--rm-control-compact);
height: var(--rm-control-compact);
padding: 0;
border: 0;
border-radius: 6px;
@@ -71,22 +73,22 @@ button {
/* ---- 登录 ---- */
.rm-auth { display: flex; align-items: center; justify-content: center; min-height: 100%; padding: 24px; box-sizing: border-box; }
.rm-auth-card { width: 100%; max-width: 340px; display: flex; flex-direction: column; gap: 14px; }
.rm-auth-card h1 { margin: 0 0 8px; font-size: 26px; color: var(--rm-accent-strong); }
.rm-auth-hint { margin: 0; font-size: 13px; color: var(--rm-text-muted); line-height: 1.6; }
.rm-auth-card label { display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: var(--rm-text-muted); }
.rm-auth-card h1 { margin: 0 0 8px; font-size: var(--rm-ui-font-title); color: var(--rm-accent-strong); }
.rm-auth-hint { margin: 0; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); line-height: 1.6; }
.rm-auth-card label { display: flex; flex-direction: column; gap: 6px; font-size: var(--rm-ui-font-size); color: var(--rm-text-muted); }
.rm-auth-card input {
height: 40px;
height: var(--rm-control-size);
padding: 0 12px;
border: 1px solid var(--rm-divider);
border-radius: 8px;
background: var(--rm-bg);
color: var(--rm-text);
font-size: 15px;
font-size: var(--rm-ui-font-size);
outline: none;
}
.rm-auth-card input:focus { border-color: var(--rm-accent); box-shadow: 0 0 0 3px var(--rm-selection); }
.rm-auth-card .rm-btn { height: 40px; justify-content: center; font-size: 15px; }
.rm-auth-error { margin: 0; color: var(--rm-danger); font-size: 13px; }
.rm-auth-card .rm-btn { height: var(--rm-control-size); justify-content: center; font-size: var(--rm-ui-font-size); }
.rm-auth-error { margin: 0; color: var(--rm-danger); font-size: var(--rm-ui-font-small); }
/* ---- 布局 ---- */
.rm-shell { display: flex; flex-direction: column; height: 100%; }
@@ -108,19 +110,19 @@ button {
/* ---- 大纲 ---- */
.rm-outline-backdrop { display: contents; }
.rm-outline { flex: none; width: 240px; display: flex; flex-direction: column; border-left: 1px solid var(--rm-divider); background: var(--rm-sidebar-bg); }
.rm-outline-head { display: flex; align-items: center; justify-content: space-between; padding: 10px 8px 6px 16px; font-size: 13px; font-weight: 600; color: var(--rm-text-muted); }
.rm-outline-head { display: flex; align-items: center; justify-content: space-between; padding: 10px 8px 6px 16px; font-size: var(--rm-ui-font-size); font-weight: 600; color: var(--rm-text-muted); }
.rm-outline-list { flex: 1; overflow-y: auto; list-style: none; margin: 0; padding: 0 6px 12px; }
.rm-outline-list li { padding-top: 5px; padding-bottom: 5px; padding-right: 8px; border-radius: 6px; font-size: 13px; line-height: 1.4; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rm-outline-list li { padding-top: 5px; padding-bottom: 5px; padding-right: 8px; border-radius: 6px; font-size: var(--rm-ui-font-size); line-height: 1.4; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rm-outline-list li:hover { background: var(--rm-hover); }
.rm-outline-list li.on { color: var(--rm-accent-strong); background: var(--rm-active); }
.rm-backdrop,
.rm-mobile-bar { display: none; }
/* ---- 侧栏 ---- */
.rm-sidebar-inner { display: flex; flex-direction: column; height: 100%; }
.rm-sidebar-inner { display: flex; flex-direction: column; height: 100%; overflow-y: auto; }
.rm-sidebar-head { display: flex; align-items: center; gap: 2px; padding: 12px 10px 8px 16px; }
.rm-brand { flex: 1; font-weight: 600; font-size: 15px; color: var(--rm-accent-strong); }
.rm-tree { flex: 1; overflow: auto; padding: 0 6px 12px; }
.rm-brand { flex: 1; font-weight: 600; font-size: var(--rm-ui-font-size); color: var(--rm-accent-strong); }
.rm-tree { flex: 1; min-height: 100px; overflow: auto; padding: 0 6px 12px; }
.rm-tree.drop { background: var(--rm-active); }
.rm-tree-list { list-style: none; margin: 0; padding: 0; }
.rm-tree-row {
@@ -128,10 +130,10 @@ button {
display: flex;
align-items: center;
gap: 4px;
height: 30px;
height: var(--rm-control-size);
padding-right: 4px;
border-radius: 6px;
font-size: 14px;
font-size: var(--rm-ui-font-size);
cursor: pointer;
user-select: none;
}
@@ -140,14 +142,14 @@ button {
.rm-tree-row { -webkit-touch-callout: none; }
.rm-tree-row.active { background: var(--rm-active); color: var(--rm-accent-strong); }
.rm-tree-row.drop { outline: 2px solid var(--rm-accent); outline-offset: -2px; }
.rm-tree-chevron { display: inline-flex; width: 14px; color: var(--rm-text-muted); transition: transform 0.15s; }
.rm-tree-chevron { display: inline-flex; flex: none; width: var(--rm-ui-font-size); color: var(--rm-text-muted); transition: transform 0.15s; }
.rm-tree-chevron.open { transform: rotate(90deg); }
.rm-tree-chevron.hidden { visibility: hidden; }
.rm-tree-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rm-tree-input {
flex: 1;
min-width: 0;
height: 24px;
height: var(--rm-control-compact);
padding: 0 6px;
border: 1px solid var(--rm-accent);
border-radius: 4px;
@@ -156,10 +158,10 @@ button {
font: inherit;
outline: none;
}
.rm-tree-more { width: 24px; height: 24px; opacity: 0; }
.rm-tree-more { width: var(--rm-control-compact); height: var(--rm-control-compact); opacity: 0; }
.rm-tree-row:hover .rm-tree-more,
.rm-tree-row.menu-open .rm-tree-more { opacity: 1; }
.rm-tree-empty { padding: 16px; color: var(--rm-text-muted); font-size: 13px; text-align: center; }
.rm-tree-empty { padding: 16px; color: var(--rm-text-muted); font-size: var(--rm-ui-font-small); text-align: center; }
/* ---- 搜索 ---- */
.rm-sidebar-head .rm-icon-btn.on { background: var(--rm-active); color: var(--rm-accent-strong); }
.rm-search { flex: 1; display: flex; flex-direction: column; min-height: 0; }
@@ -178,12 +180,12 @@ button {
.rm-search-input {
flex: 1;
min-width: 0;
height: 30px;
height: var(--rm-control-size);
border: 0;
background: none;
color: var(--rm-text);
font: inherit;
font-size: 14px;
font-size: var(--rm-ui-font-size);
outline: none;
}
.rm-search-input::-webkit-search-cancel-button { display: none; }
@@ -191,12 +193,12 @@ button {
.rm-search-item { padding: 6px 8px; border-radius: 6px; cursor: pointer; }
.rm-search-item.active { background: var(--rm-hover); }
.rm-search-item.current .rm-search-title { color: var(--rm-accent-strong); }
.rm-search-title { display: flex; align-items: baseline; gap: 6px; font-size: 14px; overflow: hidden; white-space: nowrap; }
.rm-search-dir { min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--rm-text-muted); font-size: 12px; }
.rm-search-title { display: flex; align-items: baseline; gap: 6px; font-size: var(--rm-ui-font-size); overflow: hidden; white-space: nowrap; }
.rm-search-dir { min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--rm-text-muted); font-size: var(--rm-ui-font-small); }
.rm-search-snippet {
margin-top: 2px;
color: var(--rm-text-muted);
font-size: 12px;
font-size: var(--rm-ui-font-small);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
@@ -219,10 +221,10 @@ button {
overflow: hidden;
}
.rm-switcher-list { flex: 1; overflow: auto; list-style: none; margin: 0; padding: 0 6px 6px; }
.rm-switcher-item { display: flex; align-items: center; gap: 8px; height: 34px; padding: 0 10px; border-radius: 6px; font-size: 14px; color: var(--rm-text-muted); cursor: pointer; }
.rm-switcher-item { display: flex; align-items: center; gap: 8px; height: var(--rm-control-size); padding: 0 10px; border-radius: 6px; font-size: var(--rm-ui-font-size); color: var(--rm-text-muted); cursor: pointer; }
.rm-switcher-item.active { background: var(--rm-active); }
.rm-switcher-title { color: var(--rm-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rm-switcher-dir { flex: 1; min-width: 0; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: right; }
.rm-switcher-dir { flex: 1; min-width: 0; font-size: var(--rm-ui-font-small); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: right; }
.rm-switcher-keys span { padding: 1px 6px; border: 1px solid var(--rm-divider); border-radius: 4px; font-family: inherit; }
.rm-sidebar-foot { display: flex; gap: 4px; padding: 8px; border-top: 1px solid var(--rm-divider); }
.rm-foot-btn {
@@ -231,12 +233,12 @@ button {
align-items: center;
justify-content: center;
gap: 6px;
height: 32px;
height: var(--rm-control-size);
border: 0;
border-radius: 6px;
background: transparent;
color: var(--rm-text-muted);
font-size: 13px;
font-size: var(--rm-ui-font-size);
cursor: pointer;
}
.rm-foot-btn:hover { background: var(--rm-hover); color: var(--rm-text); }
@@ -261,12 +263,12 @@ button {
display: flex;
align-items: center;
gap: 8px;
height: 32px;
height: var(--rm-control-size);
padding: 0 10px;
border: 0;
border-radius: 6px;
background: transparent;
font-size: 13px;
font-size: var(--rm-ui-font-size);
text-align: left;
cursor: pointer;
}
@@ -299,10 +301,10 @@ button {
align-items: center;
gap: 4px;
max-width: 200px;
height: 34px;
height: var(--rm-control-size);
padding: 0 4px 0 12px;
border-radius: 8px 8px 0 0;
font-size: 13px;
font-size: var(--rm-ui-font-size);
color: var(--rm-text-muted);
cursor: pointer;
flex: none;
@@ -314,10 +316,11 @@ button {
.rm-tab-close {
position: relative;
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
width: var(--rm-control-compact);
height: var(--rm-control-compact);
padding: 0;
border: 0;
border-radius: 4px;
@@ -343,7 +346,7 @@ button {
align-items: center;
gap: 8px;
padding: 8px 16px;
font-size: 13px;
font-size: var(--rm-ui-font-small);
flex: none;
}
.rm-banner span { flex: 1; min-width: 200px; }
@@ -368,36 +371,36 @@ button {
box-shadow: var(--rm-shadow);
}
.rm-dialog-head { display: flex; align-items: center; padding: 14px 16px 8px 20px; }
.rm-dialog-head h2 { flex: 1; margin: 0; font-size: 16px; }
.rm-dialog-head h2 { flex: 1; margin: 0; font-size: var(--rm-ui-font-size); }
.rm-share-dialog { max-width: 640px; }
.rm-share-form { display: flex; gap: 8px; padding: 4px 20px 0; }
.rm-share-form input,
.rm-share-form select {
height: 34px;
height: var(--rm-control-size);
padding: 0 10px;
border: 1px solid var(--rm-divider);
border-radius: 6px;
background: var(--rm-bg);
color: var(--rm-text);
font-size: 14px;
font-size: var(--rm-ui-font-size);
outline: none;
}
.rm-share-form input { flex: 1; min-width: 0; }
.rm-share-form input:focus,
.rm-share-form select:focus { border-color: var(--rm-accent); }
.rm-share-note { margin: 10px 20px 4px; font-size: 12px; color: var(--rm-text-muted); line-height: 1.6; }
.rm-share-note { margin: 10px 20px 4px; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); line-height: 1.6; }
.rm-share-note a { color: var(--rm-accent-strong); }
.rm-share-link { font-family: var(--rm-font-mono); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rm-share-link { font-family: var(--rm-font-mono); font-size: var(--rm-ui-font-small); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rm-share-bad { color: var(--rm-danger); }
.rm-token-created { margin: 12px 20px 0; padding: 10px 12px; border-radius: 8px; background: var(--rm-selection); font-size: 13px; }
.rm-token-created { margin: 12px 20px 0; padding: 10px 12px; border-radius: 8px; background: var(--rm-selection); font-size: var(--rm-ui-font-small); }
.rm-token-created p { margin: 0 0 6px; }
.rm-token-value { display: flex; align-items: center; gap: 8px; }
.rm-token-value code { flex: 1; min-width: 0; overflow-wrap: anywhere; font-family: var(--rm-font-mono); font-size: 12px; user-select: all; }
.rm-token-value code { flex: 1; min-width: 0; overflow-wrap: anywhere; font-family: var(--rm-font-mono); font-size: var(--rm-ui-font-small); user-select: all; }
/* ---- 手机键盘上方的格式工具条 ---- */
.rm-format-bar { flex: none; display: flex; height: 44px; border-top: 1px solid var(--rm-divider); background: var(--rm-sidebar-bg); }
.rm-format-bar { flex: none; display: flex; height: var(--rm-control-size); border-top: 1px solid var(--rm-divider); background: var(--rm-sidebar-bg); }
.rm-format-scroll { flex: 1; display: flex; overflow-x: auto; scrollbar-width: none; }
.rm-format-scroll::-webkit-scrollbar { display: none; }
.rm-format-bar button { flex: none; min-width: 44px; height: 44px; padding: 0 6px; border: 0; background: none; color: var(--rm-text); font-size: 17px; touch-action: manipulation; }
.rm-format-bar button { flex: none; min-width: var(--rm-control-compact); height: var(--rm-control-size); padding: 0 6px; border: 0; background: none; color: var(--rm-text); font-size: var(--rm-ui-font-size); touch-action: manipulation; }
.rm-format-bar button:active { background: var(--rm-hover); }
.rm-format-bar .bold { font-weight: 700; }
.rm-format-bar .italic { font-style: italic; font-family: Georgia, serif; }
@@ -412,32 +415,32 @@ button {
.rm-settings-nav .rm-dialog-head { padding-left: 12px; }
.rm-settings-close-list,
.rm-settings-back { display: none; }
.rm-settings-tab { display: flex; align-items: center; gap: 10px; height: 36px; padding: 0 12px; border: 0; border-radius: 8px; background: none; color: var(--rm-text); font: inherit; font-size: 14px; text-align: left; cursor: pointer; }
.rm-settings-tab { display: flex; align-items: center; gap: 10px; height: var(--rm-control-size); padding: 0 12px; border: 0; border-radius: 8px; background: none; color: var(--rm-text); font: inherit; font-size: var(--rm-ui-font-size); text-align: left; cursor: pointer; }
.rm-settings-tab:hover { background: var(--rm-hover); }
.rm-settings-tab.on { background: var(--rm-selection); color: var(--rm-accent-strong); font-weight: 600; }
.rm-settings-body { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.rm-settings-content { flex: 1; overflow-y: auto; padding-bottom: 16px; }
.rm-setting { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 44px; padding: 6px 20px; }
.rm-setting-label { font-size: 14px; }
.rm-setting select { height: 34px; padding: 0 8px; border: 1px solid var(--rm-divider); border-radius: 6px; background: var(--rm-bg); color: var(--rm-text); font-size: 14px; }
.rm-segmented { display: inline-flex; border: 1px solid var(--rm-divider); border-radius: 8px; overflow: hidden; }
.rm-segmented button { height: 32px; padding: 0 14px; border: 0; border-left: 1px solid var(--rm-divider); background: none; color: var(--rm-text); font: inherit; font-size: 13px; cursor: pointer; }
.rm-setting { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: var(--rm-control-size); padding: 6px 20px; }
.rm-setting-label { font-size: var(--rm-ui-font-size); }
.rm-setting select { height: var(--rm-control-size); padding: 0 8px; border: 1px solid var(--rm-divider); border-radius: 6px; background: var(--rm-bg); color: var(--rm-text); font-size: var(--rm-ui-font-size); }
.rm-segmented { display: inline-flex; flex-wrap: wrap; max-width: 100%; border: 1px solid var(--rm-divider); border-radius: 8px; overflow: hidden; }
.rm-segmented button { flex: 1; height: var(--rm-control-size); padding: 0 14px; border: 0; border-left: 1px solid var(--rm-divider); background: none; color: var(--rm-text); font: inherit; font-size: var(--rm-ui-font-size); cursor: pointer; }
.rm-segmented button:first-child { border-left: 0; }
.rm-segmented button.on { background: var(--rm-accent); color: #fff; }
.rm-settings-form { max-width: 360px; padding: 4px 20px 16px; }
.rm-settings-form h3 { margin: 0; font-size: 15px; }
.rm-shortcuts { width: calc(100% - 40px); margin: 0 20px; border-collapse: collapse; font-size: 14px; }
.rm-settings-form h3 { margin: 0; font-size: var(--rm-ui-font-size); }
.rm-shortcuts { width: calc(100% - 40px); margin: 0 20px; border-collapse: collapse; font-size: var(--rm-ui-font-size); }
.rm-shortcuts td { padding: 8px 0; border-bottom: 1px solid var(--rm-divider); }
.rm-shortcuts td:last-child { text-align: right; }
.rm-shortcuts kbd { font-family: var(--rm-font-mono); font-size: 12px; padding: 2px 6px; border: 1px solid var(--rm-divider); border-radius: 4px; background: var(--rm-inline-code-bg); }
.rm-shortcuts kbd { font-family: var(--rm-font-mono); font-size: var(--rm-ui-font-small); padding: 2px 6px; border: 1px solid var(--rm-divider); border-radius: 4px; background: var(--rm-inline-code-bg); }
.flip { transform: rotate(180deg); }
.rm-trash-list { list-style: none; margin: 0; padding: 0 12px 12px; overflow: auto; }
.rm-trash-list li { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 8px; color: var(--rm-text-muted); }
.rm-trash-list li:hover { background: var(--rm-hover); }
.rm-trash-info { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.rm-trash-path { color: var(--rm-text); font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rm-trash-time { font-size: 12px; }
.rm-trash-path { color: var(--rm-text); font-size: var(--rm-ui-font-size); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rm-trash-time { font-size: var(--rm-ui-font-small); }
/* ---- 通知 ---- */
.rm-toasts { position: fixed; left: 50%; bottom: 24px; z-index: 60; transform: translateX(-50%); display: flex; flex-direction: column; gap: 8px; align-items: center; pointer-events: none; }
@@ -449,23 +452,23 @@ button {
border-radius: 8px;
background: #1f2328;
color: #fff;
font-size: 13px;
font-size: var(--rm-ui-font-small);
box-shadow: var(--rm-shadow);
pointer-events: auto;
}
.rm-toast-action { border: 0; background: none; color: #42d392; font-weight: 600; cursor: pointer; padding: 0; }
/* ---- 移动端 ---- */
@media (max-width: 768px) {
@media (max-width: 767px) {
/* 设置在手机上全屏,一次只显示分组列表或分组内容 */
.rm-dialog-backdrop:has(.rm-settings) { padding: 0; }
.rm-settings { max-width: none; height: 100%; border-radius: 0; padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); box-sizing: border-box; }
.rm-settings-nav { width: auto; flex: 1; border-right: 0; display: none; }
.rm-settings.show-list .rm-settings-nav { display: flex; }
.rm-settings.show-list .rm-settings-body { display: none; }
.rm-settings-tab { height: 48px; font-size: 16px; }
.rm-settings-tab { height: 48px; font-size: var(--rm-ui-font-size); }
.rm-settings-close-list,
.rm-settings-back { display: inline-flex; width: 40px; height: 40px; }
.rm-settings-back { display: inline-flex; width: var(--rm-control-compact); height: var(--rm-control-size); }
.rm-setting { flex-wrap: wrap; }
.rm-sidebar {
position: fixed;
@@ -489,62 +492,64 @@ button {
border-bottom: 1px solid var(--rm-divider);
flex: none;
}
.rm-mobile-bar .rm-icon-btn { width: 44px; height: 44px; }
.rm-mobile-title { flex: 1; min-width: 0; height: 40px; padding: 0 4px; border: 0; background: none; color: var(--rm-text); font: inherit; font-weight: 600; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rm-mobile-bar .rm-icon-btn { width: var(--rm-control-compact); height: var(--rm-control-size); }
.rm-mobile-title { flex: 1; min-width: 0; height: var(--rm-control-size); padding: 0 4px; border: 0; background: none; color: var(--rm-text); font: inherit; font-weight: 600; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sidebar-collapsed .rm-sidebar { display: block; }
.rm-sidebar-resize { display: none; }
/* 大纲在手机上为从右侧滑入的浮层 */
.rm-outline-backdrop { display: block; position: fixed; inset: 0; z-index: 41; background: rgba(0, 0, 0, 0.3); }
.rm-outline { position: absolute; top: 0; right: 0; bottom: 0; width: min(80vw, 300px); padding-top: env(safe-area-inset-top); box-shadow: var(--rm-shadow); }
.rm-outline-list li { padding-top: 12px; padding-bottom: 12px; font-size: 15px; }
.rm-outline-list li { padding-top: 12px; padding-bottom: 12px; font-size: var(--rm-ui-font-size); }
.rm-save-state { width: 8px; height: 8px; margin-right: 8px; border-radius: 50%; background: transparent; }
.rm-save-state.on { background: var(--rm-accent); }
/* 触控热区不小于 44px */
.rm-tree-row { height: 44px; font-size: 15px; }
.rm-tree-more { opacity: 1; width: 44px; height: 44px; }
.rm-sidebar-head .rm-icon-btn { width: 44px; height: 44px; }
.rm-tree-row { height: var(--rm-control-size); font-size: var(--rm-ui-font-size); }
.rm-tree-more { opacity: 1; width: var(--rm-control-compact); height: var(--rm-control-size); }
.rm-sidebar-head .rm-icon-btn { width: var(--rm-control-compact); height: var(--rm-control-size); }
/* 小于 16px 时 iOS Safari 聚焦输入框会自动放大页面 */
.rm-search-input { height: 40px; font-size: 16px; }
.rm-search-input { height: var(--rm-control-size); font-size: var(--rm-ui-font-size); }
.rm-auth-card input,
.rm-share-form input,
.rm-share-form select,
.rm-setting select { font-size: 16px; }
.rm-setting select { font-size: var(--rm-ui-font-size); }
.rm-share-form { flex-wrap: wrap; }
.rm-search-item { padding: 10px 8px; }
/* 手机上从顶部弹出,给软键盘留出空间 */
.rm-switcher-backdrop { padding-top: calc(8px + env(safe-area-inset-top)); }
.rm-switcher { max-height: 50vh; }
.rm-switcher-item { height: 44px; font-size: 15px; }
.rm-switcher-item { height: var(--rm-control-size); font-size: var(--rm-ui-font-size); }
.rm-trash-list li { flex-wrap: wrap; }
}
/* 主题与 CSS 编辑 */
.rm-appearance-note { margin: 8px 20px 16px; font-size: 13px; color: var(--rm-text-muted); overflow-wrap: anywhere; }
.rm-appearance-note { margin: 8px 20px 16px; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); overflow-wrap: anywhere; }
.rm-snippet-row { display: flex; align-items: center; gap: 8px; padding: 6px 20px; }
.rm-snippet-row label { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; overflow-wrap: anywhere; font-size: 14px; }
.rm-snippet-row label { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; overflow-wrap: anywhere; font-size: var(--rm-ui-font-size); }
.rm-snippet-row input { accent-color: var(--rm-accent); flex: none; }
.rm-snippet-form { display: flex; flex-direction: column; gap: 12px; padding: 12px 20px; }
.rm-snippet-form label { display: flex; flex-direction: column; gap: 6px; font-size: 13px; }
.rm-snippet-form label { display: flex; flex-direction: column; gap: 6px; font-size: var(--rm-ui-font-size); }
.rm-snippet-form input, .rm-snippet-form textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: 8px; border: 1px solid var(--rm-divider); border-radius: 6px; background: var(--rm-bg); color: var(--rm-text); font: inherit; }
.rm-snippet-form textarea { font-family: var(--rm-font-mono); resize: vertical; }
@media (pointer: coarse) { .rm-snippet-row .rm-btn, .rm-snippet-form .rm-btn { min-height: 44px; } .rm-snippet-form input, .rm-snippet-form textarea { font-size: 16px; } }
@media (pointer: coarse) { .rm-snippet-row .rm-btn, .rm-snippet-form .rm-btn { min-height: var(--rm-control-size); } .rm-snippet-form input, .rm-snippet-form textarea { font-size: var(--rm-ui-font-size); } }
.rm-plugin-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 12px 20px; border-bottom: 1px solid var(--rm-divider); }
.rm-plugin-row label { display: flex; align-items: center; flex: 1; min-width: 0; gap: 8px; overflow-wrap: anywhere; }
.rm-plugin-row small { display: block; color: var(--rm-text-muted); font-size: 12px; }
.rm-plugin-row p { width: 100%; font-size: 13px; }
.rm-plugin-row small { display: block; color: var(--rm-text-muted); font-size: var(--rm-ui-font-small); }
.rm-plugin-row p { width: 100%; font-size: var(--rm-ui-font-small); }
.rm-plugin-row input { accent-color: var(--rm-accent); flex: none; }
.rm-plugin-view { padding: 16px; min-width: 0; overflow: auto; overflow-wrap: anywhere; }
.rm-sidebar-inner > .rm-plugin-view { flex: 1; }
.rm-plugin-tabs { display: flex; flex: none; gap: 4px; padding: 8px; overflow-x: auto; }
.rm-plugin-tabs .on { background: var(--rm-active); color: var(--rm-accent-strong); }
@media (pointer: coarse) { .rm-plugin-row .rm-btn, .rm-plugin-tabs .rm-btn { min-height: 44px; } }
@media (pointer: coarse) { .rm-plugin-row .rm-btn, .rm-plugin-tabs .rm-btn { min-height: var(--rm-control-size); } }
.rm-font-input input { width: 80px; padding: 7px; border: 1px solid var(--rm-divider); border-radius: 6px; background: var(--rm-bg); color: var(--rm-text); }
.rm-ui-font-control { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.rm-font-input { display: inline-flex; align-items: center; gap: 6px; }
.rm-font-input input { min-height: var(--rm-control-size); box-sizing: border-box; font: inherit; width: 80px; padding: 7px; border: 1px solid var(--rm-divider); border-radius: 6px; background: var(--rm-bg); color: var(--rm-text); }
.rm-tab-menu { right: auto; width: max-content; max-width: calc(100vw - 8px); }
.rm-shortcut-settings { padding: 12px 20px; }
.rm-shortcut-row { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; padding: 12px 0; border-bottom: 1px solid var(--rm-divider); align-items: center; font-size: 13px; }
.rm-shortcut-row { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; padding: 12px 0; border-bottom: 1px solid var(--rm-divider); align-items: center; font-size: var(--rm-ui-font-size); }
.rm-shortcut-bindings { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.rm-shortcut-chip { display: inline-flex; align-items: center; gap: 3px; padding-left: 6px; border: 1px solid var(--rm-divider); border-radius: 5px; }
.rm-shortcut-bindings input { width: 130px; padding: 8px; color: var(--rm-text); background: var(--rm-bg); border: 1px solid var(--rm-accent); }
@@ -554,116 +559,120 @@ button {
.rm-tree-row.selected { background: var(--rm-active); box-shadow: inset 2px 0 var(--rm-accent); }
/* 轻玻璃工作台 */
.rm-global-header { height: 56px; flex: none; display: flex; align-items: center; gap: 18px; padding: 0 24px; border-bottom: 1px solid var(--rm-divider); background: color-mix(in srgb, var(--rm-bg) calc(var(--rm-glass-opacity) * 100%), transparent); backdrop-filter: blur(18px); z-index: 10; }
.rm-wordmark { display: flex; align-items: center; gap: 10px; background: none; border: 0; padding: 0; font-family: var(--rm-font-heading); font-size: 24px; font-weight: 600; cursor: pointer; }
.rm-global-header { height: var(--rm-header-height); flex: none; display: flex; align-items: center; gap: 18px; padding: 0 24px; border-bottom: 1px solid var(--rm-divider); background: color-mix(in srgb, var(--rm-bg) calc(var(--rm-glass-opacity) * 100%), transparent); backdrop-filter: blur(18px); z-index: 10; }
.rm-wordmark { flex: none; display: flex; align-items: center; gap: 10px; background: none; border: 0; padding: 0; font-family: var(--rm-font-heading); font-size: var(--rm-ui-font-title); font-weight: 600; cursor: pointer; }
.rm-wordmark svg { color: var(--rm-accent); }
.rm-edition { font-size: 10px; color: var(--rm-text-muted); padding: 4px 8px; border-radius: 5px; background: var(--rm-sidebar-bg); }
.rm-primary-nav { display: flex; align-self: stretch; gap: 18px; margin-left: 12px; }
.rm-primary-nav button { display: flex; align-items: center; gap: 7px; border: 0; border-bottom: 2px solid transparent; background: none; font-size: 12px; cursor: pointer; padding: 0 3px; }
.rm-edition { font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); padding: 4px 8px; border-radius: 5px; background: var(--rm-sidebar-bg); }
.rm-primary-nav { flex: none; display: flex; align-self: stretch; gap: 18px; margin-left: 12px; }
.rm-primary-nav button { display: flex; align-items: center; gap: 7px; border: 0; border-bottom: 2px solid transparent; background: none; font-size: var(--rm-ui-font-size); cursor: pointer; padding: 0 3px; }
.rm-primary-nav .on { color: var(--rm-accent); border-bottom-color: var(--rm-accent); }
.rm-global-search { display: flex; align-items: center; gap: 10px; flex: 1; max-width: 330px; min-width: 120px; margin-left: auto; padding: 8px 12px; color: var(--rm-text-muted); border: 1px solid var(--rm-divider); background: var(--rm-sidebar-bg); border-radius: 8px; text-align: left; font-size: 11px; cursor: pointer; }
.rm-global-search kbd { font: inherit; font-size: 9px; margin-left: auto; opacity: .7; white-space: nowrap; }
.rm-header-actions { display: flex; align-items: center; gap: 10px; }
.rm-header-actions .rm-btn { height: 34px; font-size: 11px; }
.rm-avatar { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; flex: none; border-radius: 50%; background: var(--rm-selection); color: var(--rm-accent-strong); font-size: 10px; font-weight: 700; }
.rm-global-search { box-sizing: border-box; min-height: var(--rm-control-size); display: flex; align-items: center; gap: 10px; flex: 1; max-width: 330px; min-width: 0; margin-left: auto; padding: 8px 12px; color: var(--rm-text-muted); border: 1px solid var(--rm-divider); background: var(--rm-sidebar-bg); border-radius: 8px; text-align: left; font-size: var(--rm-ui-font-size); cursor: pointer; }
.rm-global-search > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rm-global-search kbd { flex: none; font: inherit; font-size: var(--rm-ui-font-caption); margin-left: auto; opacity: .7; white-space: nowrap; }
.rm-header-actions { flex: none; display: flex; align-items: center; gap: 10px; }
.rm-header-actions .rm-btn { height: var(--rm-control-size); font-size: var(--rm-ui-font-size); }
.rm-avatar { display: inline-flex; align-items: center; justify-content: center; width: var(--rm-control-size); height: var(--rm-control-size); flex: none; border-radius: 50%; background: var(--rm-selection); color: var(--rm-accent-strong); font-size: var(--rm-ui-font-caption); font-weight: 700; }
.rm-vault-heading { display: flex; align-items: center; gap: 10px; padding: 20px 16px 10px; }
.rm-vault-heading > div { flex: 1; min-width: 0; }
.rm-vault-heading strong, .rm-vault-status strong { display: block; font-size: 12px; font-weight: 600; }
.rm-vault-heading small, .rm-vault-status small { display: block; margin-top: 4px; color: var(--rm-text-muted); font-size: 9px; }
.rm-vault-icon { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 8px; background: var(--rm-active); color: var(--rm-accent); }
.rm-sidebar-head { display: grid; grid-template-columns: repeat(4, 1fr); gap: 2px; padding: 14px 12px 22px; border-bottom: 1px solid var(--rm-divider); }
.rm-sidebar-head .rm-icon-btn { width: 100%; height: 54px; flex-direction: column; gap: 8px; font-size: 10px; }
.rm-tree-label { padding: 22px 16px 12px; font-size: 11px; font-weight: 600; color: var(--rm-text-muted); }
.rm-tree-label span { font-size: 8px; margin-left: 4px; opacity: .7; }
.rm-vault-heading strong, .rm-vault-status strong { display: block; font-size: var(--rm-ui-font-size); font-weight: 600; }
.rm-vault-heading small, .rm-vault-status small { display: block; margin-top: 4px; color: var(--rm-text-muted); font-size: var(--rm-ui-font-caption); }
.rm-vault-icon { display: grid; flex: none; place-items: center; width: var(--rm-control-size); height: var(--rm-control-size); border-radius: 8px; background: var(--rm-active); color: var(--rm-accent); }
.rm-sidebar-head { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; padding: 14px 12px 22px; border-bottom: 1px solid var(--rm-divider); }
.rm-sidebar-head .rm-icon-btn { min-width: 0; width: 100%; min-height: var(--rm-control-size); height: auto; padding: 10px 6px; flex-direction: row; justify-content: flex-start; gap: 8px; font-size: var(--rm-ui-font-size); }
.rm-tree-label { padding: 22px 16px 12px; font-size: var(--rm-ui-font-small); font-weight: 600; color: var(--rm-text-muted); }
.rm-tree-label span { font-size: var(--rm-ui-font-caption); margin-left: 4px; opacity: .7; }
.rm-tree { padding: 0 10px 16px; }
.rm-tree-row { height: 36px; font-size: 12px; gap: 6px; }
.rm-tree-row { height: var(--rm-control-size); font-size: var(--rm-ui-font-size); gap: 6px; }
.rm-tree-row.active { box-shadow: inset 3px 0 var(--rm-accent); }
.rm-vault-status { display: flex; gap: 10px; align-items: center; padding: 14px 16px 4px; border-top: 1px solid var(--rm-divider); }
.rm-sidebar-foot { border: 0; padding: 8px 10px 12px; }
.rm-foot-btn { height: 32px; font-size: 11px; }
.rm-foot-btn { height: var(--rm-control-size); font-size: var(--rm-ui-font-size); }
.rm-outline { width: 256px; background: color-mix(in srgb, var(--rm-sidebar-bg) 60%, transparent); }
.rm-outline-head { padding: 18px 14px; font-size: 11px; }
.rm-outline-head { padding: 18px 14px; font-size: var(--rm-ui-font-size); }
.rm-outline-list { padding: 8px 12px; }
.rm-outline-list li { padding-top: 9px; padding-bottom: 9px; font-size: 11px; }
.rm-outline-list li { padding-top: 9px; padding-bottom: 9px; font-size: var(--rm-ui-font-size); }
.rm-outline-list li.on { box-shadow: inset 3px 0 var(--rm-accent); }
.rm-tabs { min-height: 40px; padding: 5px 12px 0; background: color-mix(in srgb, var(--rm-sidebar-bg) 45%, transparent); }
.rm-tab { height: 34px; max-width: 240px; font-size: 11px; border: 1px solid transparent; border-radius: 7px 7px 0 0; }
.rm-tabs { height: auto; min-height: calc(var(--rm-control-size) + 4px); padding: 5px 12px 0; background: color-mix(in srgb, var(--rm-sidebar-bg) 45%, transparent); }
.rm-tab { min-width: 0; height: var(--rm-control-size); max-width: 240px; font-size: var(--rm-ui-font-size); border: 1px solid transparent; border-radius: 7px 7px 0 0; }
.rm-tab.active { background: var(--rm-active); color: var(--rm-accent-strong); border-color: var(--rm-divider); box-shadow: 0 2px 5px rgba(19,27,46,.03); }
.rm-tab-add { margin: 4px; }
.rm-editor-chrome { flex: none; padding: 14px 28px 16px; border-bottom: 1px solid var(--rm-divider); }
.rm-breadcrumb { display: flex; align-items: center; gap: 10px; font-size: 10px; color: var(--rm-text-muted); overflow: hidden; white-space: nowrap; }
.rm-breadcrumb { min-width: 0; display: flex; align-items: center; gap: 10px; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); overflow: hidden; white-space: nowrap; }
.rm-breadcrumb .current { color: var(--rm-accent); overflow: hidden; text-overflow: ellipsis; }
.rm-crumb-separator { opacity: .4; }
.rm-document-meta { display: flex; flex-wrap: wrap; gap: 8px 16px; font-size: 9px; color: var(--rm-text-muted); margin-top: 10px; }
.rm-document-meta { display: flex; flex-wrap: wrap; gap: 8px 16px; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); margin-top: 10px; }
.rm-document-meta strong { font-weight: 500; color: var(--rm-text); }
.rm-document-actions { display: flex; align-items: center; gap: 8px; margin-top: 18px; }
.rm-document-actions .rm-btn { font-size: 10px; height: 28px; }
.rm-mode-switch { display: inline-flex; padding: 3px; border-radius: 9px; background: var(--rm-sidebar-bg); }
.rm-mode-switch button { display: flex; align-items: center; gap: 6px; padding: 6px 12px; font-size: 10px; border: 0; border-radius: 6px; background: none; color: var(--rm-text-muted); cursor: pointer; }
.rm-document-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 18px; }
.rm-mode-switch { flex: none; display: inline-flex; padding: 3px; border-radius: 9px; background: var(--rm-sidebar-bg); }
.rm-mode-switch button { min-height: var(--rm-control-size); display: flex; align-items: center; gap: 6px; padding: 6px 12px; font-size: var(--rm-ui-font-size); border: 0; border-radius: 6px; background: none; color: var(--rm-text-muted); cursor: pointer; }
.rm-mode-switch .on { background: var(--rm-active); color: var(--rm-accent-strong); box-shadow: 0 1px 4px rgba(19,27,46,.06); }
.rm-document-filename { margin-left: auto; font-size: 9px; color: var(--rm-text-muted); text-overflow: ellipsis; overflow: hidden; white-space: nowrap; }
.rm-document-filename { margin-left: auto; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); text-overflow: ellipsis; overflow: hidden; white-space: nowrap; }
.focus-mode .rm-sidebar, .focus-mode .rm-outline-backdrop { display: none; }
.rm-btn { border-radius: 8px; background: color-mix(in srgb, var(--rm-bg) 80%, transparent); }
.rm-btn-primary { background: var(--rm-accent); box-shadow: 0 2px 5px var(--rm-selection); }
.rm-auth-card { gap: 18px; padding: 40px; border: 1px solid var(--rm-divider); border-radius: 24px; background: color-mix(in srgb, var(--rm-bg) 75%, transparent); box-shadow: var(--rm-shadow); }
.rm-auth-card { box-sizing: border-box; gap: 18px; padding: 40px; border: 1px solid var(--rm-divider); border-radius: 24px; background: color-mix(in srgb, var(--rm-bg) 75%, transparent); box-shadow: var(--rm-shadow); }
.rm-dialog-backdrop { background: rgba(19,27,46,.15); backdrop-filter: blur(8px); }
.rm-dialog { border-radius: 20px; border: 1px solid var(--rm-divider); background: color-mix(in srgb, var(--rm-bg) 96%, transparent); }
.rm-menu { background: color-mix(in srgb, var(--rm-bg) 92%, transparent); backdrop-filter: blur(24px); border-radius: 14px; padding: 7px; }
.rm-menu button { min-height: 36px; }
.rm-menu button { min-height: var(--rm-control-size); }
@media (max-width: 1399px) {
.rm-global-search kbd { display: none; }
}
@media (min-width: 768px) and (max-width: 1199px) {
.rm-global-header { gap: 12px; padding: 0 16px; }
.rm-edition, .rm-global-search kbd { display: none; }
.rm-outline-backdrop { display: block; position: fixed; inset: 56px 0 0; z-index: 41; background: rgba(19,27,46,.12); }
.rm-edition { display: none; }
.rm-outline-backdrop { display: block; position: fixed; inset: var(--rm-header-height) 0 0; z-index: 41; background: rgba(19,27,46,.12); }
.rm-outline { position: absolute; right: 0; top: 0; bottom: 0; box-shadow: var(--rm-shadow); }
}
@media (max-width: 767px) {
.rm-global-header { display: none; }
.rm-editor-chrome { padding: 12px 16px; }
.rm-breadcrumb, .rm-document-actions { display: none; }
.rm-document-meta { font-size: 10px; margin: 0; }
.rm-document-meta { font-size: var(--rm-ui-font-small); margin: 0; }
.rm-document-meta > :nth-child(3) { display: none; }
.rm-sidebar-head .rm-icon-btn { width: 100%; height: 56px; }
.rm-tree-label { padding-top: 16px; }
.rm-tree-row { height: 44px; font-size: 14px; }
.rm-tree-row { height: var(--rm-control-size); font-size: var(--rm-ui-font-size); }
.rm-outline { width: min(84vw, 320px); }
.rm-auth-card { padding: 28px 24px; }
}
.rm-notes-view { display: flex; flex-direction: column; flex: 1; min-height: 0; position: relative; }
.rm-knowledge-nav { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; padding: 14px 12px 0; }
.rm-knowledge-nav button { display: flex; align-items: center; gap: 8px; padding: 9px 8px; font-size: 10px; border: 0; border-radius: 7px; background: none; color: var(--rm-text-muted); cursor: pointer; }
.rm-knowledge-nav { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, calc(var(--rm-ui-font-size) * 5 + 48px)), 1fr)); gap: 4px; padding: 14px 12px 0; }
.rm-knowledge-nav button { min-height: var(--rm-control-size); overflow-wrap: anywhere; display: flex; align-items: center; gap: 8px; padding: 9px 8px; font-size: var(--rm-ui-font-size); border: 0; border-radius: 7px; background: none; color: var(--rm-text-muted); cursor: pointer; }
.rm-knowledge-nav button:hover, .rm-knowledge-nav .on { background: var(--rm-active); color: var(--rm-accent); }
/* 偏好设置沿用即时保存,在主画布中展开。 */
.rm-dialog-backdrop:has(.rm-settings) { top: 56px; padding: 28px; background: radial-gradient(ellipse at 70% 10%, var(--rm-selection), transparent 65%), var(--rm-bg); backdrop-filter: none; }
.rm-dialog-backdrop:has(.rm-settings) { top: var(--rm-header-height); padding: 28px; background: radial-gradient(ellipse at 70% 10%, var(--rm-selection), transparent 65%), var(--rm-bg); backdrop-filter: none; }
.rm-settings { max-width: 1250px; width: 100%; height: 100%; background: none; border: 0; border-radius: 0; box-shadow: none; gap: 28px; }
.rm-settings-nav { width: 228px; border: 1px solid var(--rm-divider); border-radius: 16px; padding: 18px 12px; background: color-mix(in srgb, var(--rm-sidebar-bg) calc(var(--rm-glass-opacity) * 100%), transparent); backdrop-filter: blur(18px); }
.rm-settings-nav .rm-dialog-head { padding: 8px 10px 24px; border-bottom: 1px solid var(--rm-divider); margin-bottom: 16px; }
.rm-settings-nav h2 { font-size: 13px; } .rm-settings-nav h2 small { display: block; font: 9px var(--rm-font-text); color: var(--rm-text-muted); margin-top: 6px; }
.rm-settings-tab { flex: none; font-size: 12px; min-height: 42px; color: var(--rm-text-muted); }
.rm-settings-nav h2 { font-size: var(--rm-ui-font-size); } .rm-settings-nav h2 small { display: block; font: var(--rm-ui-font-caption) var(--rm-font-text); color: var(--rm-text-muted); margin-top: 6px; }
.rm-settings-tab { flex: none; font-size: var(--rm-ui-font-size); min-height: var(--rm-control-size); color: var(--rm-text-muted); }
.rm-settings-tab.on { background: var(--rm-active); box-shadow: inset 3px 0 var(--rm-accent); }
.rm-settings-nav-foot { margin-top: auto; display: grid; gap: 12px; padding: 24px 10px 0; } .rm-settings-nav-foot small { font-size: 9px; color: var(--rm-text-muted); overflow-wrap: anywhere; }
.rm-settings-nav-foot { margin-top: auto; display: grid; gap: 12px; padding: 24px 10px 0; } .rm-settings-nav-foot small { font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); overflow-wrap: anywhere; }
.rm-settings-body > .rm-dialog-head { padding: 6px 2px 24px; align-items: flex-start; }
.rm-settings-heading small { font-size: 10px; color: var(--rm-accent); } .rm-settings-heading h2 { font: 600 26px var(--rm-font-heading); margin: 12px 0; } .rm-settings-heading p { font-size: 11px; color: var(--rm-text-muted); margin: 0; }
.rm-settings-heading small { font-size: var(--rm-ui-font-caption); color: var(--rm-accent); } .rm-settings-heading h2 { font: 600 var(--rm-ui-font-title) var(--rm-font-heading); margin: 12px 0; } .rm-settings-heading p { font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); margin: 0; }
.rm-settings-content { border-radius: 16px; border: 1px solid var(--rm-divider); padding: 20px 8px; background: color-mix(in srgb, var(--rm-bg) calc(var(--rm-glass-opacity) * 100%), transparent); box-shadow: 0 3px 18px rgba(19,27,46,.025); }
.rm-setting { padding: 13px 20px; font-size: 12px; } .rm-setting-label { font-size: 12px; } .rm-setting input[type=range] { width: 145px; accent-color: var(--rm-accent); }
.rm-setting { padding: 13px 20px; font-size: var(--rm-ui-font-size); } .rm-setting-label { font-size: var(--rm-ui-font-size); } .rm-setting input[type=range] { width: 145px; accent-color: var(--rm-accent); }
.rm-setting input:not([type=checkbox],[type=range]), .rm-setting select { max-width: 260px; min-width: 0; padding: 8px 10px; border: 1px solid var(--rm-divider); border-radius: 7px; background: var(--rm-bg); color: var(--rm-text); font: inherit; }
.rm-settings-group-title { font: 600 17px var(--rm-font-heading); border-top: 1px solid var(--rm-divider); margin: 26px 20px 10px; padding-top: 24px; }
.rm-settings-group-title { font: 600 var(--rm-ui-font-heading) var(--rm-font-heading); border-top: 1px solid var(--rm-divider); margin: 26px 20px 10px; padding-top: 24px; }
.rm-theme-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; padding: 6px 20px 20px; }
.rm-theme-cards button { display: flex; flex-direction: column; gap: 8px; text-align: left; padding: 12px; border: 1px solid var(--rm-divider); border-radius: 12px; background: var(--rm-active); cursor: pointer; min-width: 0; }
.rm-theme-cards .on { outline: 2px solid var(--rm-accent); outline-offset: -1px; }
.rm-theme-cards strong { font-size: 11px; font-weight: 600; } .rm-theme-cards small { font-size: 9px; color: var(--rm-text-muted); }
.rm-theme-cards strong { font-size: var(--rm-ui-font-size); font-weight: 600; } .rm-theme-cards small { font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); }
.rm-theme-sample { width: 100%; height: 60px; border-radius: 7px; background: #f2f3ff; display: flex; flex-direction: column; justify-content: center; gap: 6px; padding: 12px; box-sizing: border-box; }
.rm-theme-sample i { width: 65%; height: 3px; background: #c7c4d8; border-radius: 4px; } .rm-theme-sample i:first-child { width: 45%; height: 5px; background: #4f46e5; } .dark .rm-theme-sample { background: #1e2022; } .dark .rm-theme-sample i:first-child { background: #42d392; } .system .rm-theme-sample { background: linear-gradient(120deg,#f2f3ff 50%,#1e2022 50%); }
.rm-typography-preview { margin: 20px; padding: 24px; border-radius: 12px; border: 1px solid var(--rm-divider); background: var(--rm-sidebar-bg); font-size: var(--rm-font-size); line-height: var(--rm-line-height); }
.rm-typography-preview > small { font-size: 8px; color: var(--rm-accent); letter-spacing: .12em; } .rm-typography-preview h2 { font-family: var(--rm-font-heading); font-size: 1.5em; } .rm-typography-preview p { margin-bottom: var(--rm-paragraph-spacing); } .rm-typography-preview code { font-family: var(--rm-font-mono); font-size: .75em; color: var(--rm-inline-code-text); }
.rm-typography-preview > small { font-size: var(--rm-ui-font-caption); color: var(--rm-accent); letter-spacing: .12em; } .rm-typography-preview h2 { font-family: var(--rm-font-heading); font-size: 1.5em; } .rm-typography-preview p { margin-bottom: var(--rm-paragraph-spacing); } .rm-typography-preview code { font-family: var(--rm-font-mono); font-size: .75em; color: var(--rm-inline-code-text); }
@media(max-width: 767px) {
.rm-dialog-backdrop:has(.rm-settings) { top: 0; padding: 0; }
.rm-settings { gap: 0; } .rm-settings-nav { width: auto; border-radius: 0; padding: 20px 16px; } .rm-settings-body { padding: 16px; } .rm-settings-content { padding: 12px 0; } .rm-settings-heading h2 { font-size: 22px; } .rm-settings-body > .rm-dialog-head { gap: 10px; }
.rm-theme-cards { padding: 12px; gap: 8px; } .rm-theme-cards button { padding: 8px; } .rm-theme-cards small { font-size: 8px; }
.rm-setting { padding: 12px; } .rm-setting input:not([type=checkbox],[type=range]), .rm-setting select { font-size: 16px; max-width: 100%; } .rm-settings-tab { min-height: 48px; font-size: 14px; }
.rm-settings { gap: 0; } .rm-settings-nav { width: auto; border-radius: 0; padding: 20px 16px; } .rm-settings-body { padding: 16px; } .rm-settings-content { padding: 12px 0; } .rm-settings-heading h2 { font-size: var(--rm-ui-font-heading); } .rm-settings-body > .rm-dialog-head { gap: 10px; }
.rm-theme-cards { grid-template-columns: 1fr; padding: 12px; gap: 8px; } .rm-theme-cards button { padding: 8px; }
.rm-setting { padding: 12px; } .rm-setting input:not([type=checkbox],[type=range]), .rm-setting select { font-size: max(16px, var(--rm-ui-font-size)); max-width: 100%; } .rm-settings-tab { min-height: 48px; font-size: var(--rm-ui-font-size); }
}
.rm-selection-toolbar { position: fixed; display: flex; align-items: center; padding: 4px; border: 1px solid var(--rm-divider); border-radius: 10px; background: color-mix(in srgb, var(--rm-bg) 90%, transparent); backdrop-filter: blur(18px); box-shadow: var(--rm-shadow); z-index: 20; }
.rm-selection-toolbar button { width: 32px; height: 32px; border: 0; border-radius: 6px; background: none; cursor: pointer; font: 13px var(--rm-font-text); } .rm-selection-toolbar button:hover { background: var(--rm-selection); color: var(--rm-accent); } .rm-selection-toolbar .bold { font-weight: 700; } .rm-selection-toolbar .italic { font-style: italic; } .rm-selection-toolbar .strike { text-decoration: line-through; }
@media(min-width:768px) { .rm-format-bar { position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%); width: max-content; max-width: calc(100% - 40px); height: 40px; padding: 4px 8px; border: 1px solid var(--rm-divider); border-radius: 12px; background: color-mix(in srgb, var(--rm-bg) 85%, transparent); backdrop-filter: blur(18px); box-shadow: var(--rm-shadow); z-index: 15; } .rm-format-bar button { min-width: 32px; height: 32px; font-size: 13px; border-radius: 5px; } .rm-format-scroll { align-items: center; } }
.rm-selection-toolbar { position: fixed; display: flex; align-items: center; box-sizing: border-box; max-width: calc(100vw - 24px); overflow-x: auto; padding: 4px; border: 1px solid var(--rm-divider); border-radius: 10px; background: color-mix(in srgb, var(--rm-bg) 90%, transparent); backdrop-filter: blur(18px); box-shadow: var(--rm-shadow); z-index: 20; }
.rm-selection-toolbar button { width: var(--rm-control-compact); height: var(--rm-control-compact); flex: none; padding: 0; border: 0; border-radius: 6px; background: none; cursor: pointer; font: var(--rm-ui-font-size) var(--rm-font-text); } .rm-selection-toolbar button:hover { background: var(--rm-selection); color: var(--rm-accent); } .rm-selection-toolbar .bold { font-weight: 700; } .rm-selection-toolbar .italic { font-style: italic; } .rm-selection-toolbar .strike { text-decoration: line-through; }
@media(min-width:768px) { .rm-format-bar { box-sizing: content-box; position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%); width: max-content; max-width: calc(100% - 40px); height: var(--rm-control-size); padding: 4px 8px; border: 1px solid var(--rm-divider); border-radius: 12px; background: color-mix(in srgb, var(--rm-bg) 85%, transparent); backdrop-filter: blur(18px); box-shadow: var(--rm-shadow); z-index: 15; } .rm-format-bar button { min-width: var(--rm-control-compact); height: var(--rm-control-size); font-size: var(--rm-ui-font-size); border-radius: 5px; } .rm-format-scroll { align-items: center; } }
@media(max-width:767px) { .rm-format-bar { padding-bottom: env(safe-area-inset-bottom); } }
.rm-typography-preview { font-family: var(--rm-font-editor, var(--rm-font-text)); }
@@ -672,8 +681,22 @@ button {
.rm-dialog-backdrop:has(.rm-settings) { position: absolute; }
.rm-settings-heading { flex: 1; }
.rm-appearance-advanced { margin: 4px 0 16px; }
.rm-appearance-advanced > summary { margin: 0 20px 12px; padding: 10px 0; cursor: pointer; font-size: 11px; color: var(--rm-text-muted); }
.rm-appearance-advanced > summary { margin: 0 20px 12px; padding: 10px 0; cursor: pointer; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); }
@media(min-width:768px) { .rm-shell:has(.rm-media-library) .rm-sidebar { display: none; } }
@media(min-width:768px) and (max-width:1000px) { .rm-global-search { display: none; } .rm-header-actions { margin-left: auto; } .rm-primary-nav { margin-left: auto; } }
@media(min-width:768px) and (max-width:1000px) {
.rm-global-header { gap: 8px; padding: 0 12px; }
.rm-global-search, .rm-header-actions .rm-avatar { display: none; }
.rm-header-actions { margin-left: auto; gap: 6px; }
.rm-primary-nav { margin-left: auto; gap: 8px; }
.rm-wordmark { font-size: calc(var(--rm-ui-font-size) + 8px); }
}
.rm-setting select { height: auto; min-height: 36px; line-height: 1.4; }
@media(max-width:360px) {
.rm-sidebar-head .rm-icon-btn { flex-direction: column; justify-content: center; gap: 6px; }
}
.rm-setting select { height: auto; min-height: var(--rm-control-size); line-height: 1.4; }
@media (max-width: 767px), (pointer: coarse) {
input:not([type=checkbox],[type=radio],[type=range]), select, textarea { font-size: max(16px, var(--rm-ui-font-size)); }
}
+8 -1
View File
@@ -12,9 +12,16 @@ export async function createFromTemplate(path: string, template: string) {
return created
}
let openingDaily = false
export async function openDailyNote() {
// 模板读取也属于打开日记的过程,此时尚未加入乐观目录。
if (openingDaily) return
openingDaily = true
try {
const path = joinPath(settings.dailyFolder, localDate() + '.md')
if (ws.entries.some(entry => !entry.dir && entry.path === path)) { await openNote(path); return }
try { await createFromTemplate(path, settings.dailyTemplate) }
await createFromTemplate(path, settings.dailyTemplate)
}
catch (error) { toast('创建日记失败:' + (error instanceof Error ? error.message : '网络错误')) }
finally { openingDaily = false }
}
+73 -1
View File
@@ -6,7 +6,10 @@ import { api, ApiError } from './api'
import { drafts, type Draft } from './drafts'
import { connectEvents } from './events'
import { pluginEvents } from '../plugins/registry'
import { attachView, closeTab, editorStateOf, flush, keepLocalVersion, importNotes, move, remove, readyToReload, openNote, refreshTree, replaceImage, restore, startWorkspace, stopWorkspace, storeViewState, ws } from './workspace'
import { attachView, closeTab, createNoteAt, editorStateOf, flush, keepLocalVersion, importNotes, move, remove, readyToReload, openNote, refreshTree, replaceImage, restore, startWorkspace, stopWorkspace, storeViewState, ws } from './workspace'
import { openDailyNote } from './templates'
import { settings } from './settings'
import { localDate } from '../markdown/template'
vi.mock('./events', () => ({ connectEvents: vi.fn(() => () => {}) }))
@@ -99,6 +102,75 @@ async function edit(path: string) {
return view
}
describe('笔记打开与创建', () => {
it('从图片库新建或打开已有笔记时显示笔记视图', async () => {
vi.spyOn(api, 'writeFile').mockResolvedValue('r1')
vi.spyOn(drafts, 'delete').mockResolvedValue(undefined)
ws.images = ''
ws.view = 'graph'
expect(await createNoteAt('gallery-create.md', '正文')).toBe(true)
expect(ws.images).toBeNull()
expect(ws.view).toBe('notes')
expect(ws.active).toBe('gallery-create.md')
ws.images = ''
await openNote('gallery-create.md')
expect(ws.images).toBeNull()
expect(editorStateOf('gallery-create.md')?.doc.toString()).toBe('正文')
await closeTab('gallery-create.md')
})
it.each([true, false])('创建期间打开同一路径,创建成功=%s,不产生重复或错误标签', async (succeeds) => {
const path = `pending-create-${succeeds}.md`
let finish!: (rev: string) => void
let fail!: (error: Error) => void
vi.spyOn(api, 'writeFile').mockImplementation(() => new Promise((resolve, reject) => { finish = resolve; fail = reject }))
vi.spyOn(api, 'readFile').mockRejectedValue(new ApiError(404, '文件不存在'))
vi.spyOn(drafts, 'delete').mockResolvedValue(undefined)
const creating = createNoteAt(path, '创建内容')
const opening = openNote(path)
if (succeeds) finish('r1')
else fail(new ApiError(500, '创建写入失败'))
expect(await creating).toBe(succeeds)
await opening
expect(ws.tabs.filter(tab => tab.path === path)).toHaveLength(succeeds ? 1 : 0)
if (succeeds) {
expect(ws.tabs.find(tab => tab.path === path)?.status).toBe('ready')
expect(editorStateOf(path)?.doc.toString()).toBe('创建内容')
await closeTab(path)
} else {
expect(ws.toasts.some(toast => toast.text.includes('创建写入失败'))).toBe(true)
}
expect(ws.toasts.some(toast => toast.text.includes('打开失败:文件不存在'))).toBe(false)
})
it('日记模板读取期间重复打开只创建一个标签,之后仍可再次打开', async () => {
const previousFolder = settings.dailyFolder, previousTemplate = settings.dailyTemplate
settings.dailyFolder = 'Daily-review'
settings.dailyTemplate = 'Templates/daily.md'
const path = settings.dailyFolder + '/' + localDate() + '.md'
let finish!: (value: { content: string; rev: string }) => void
vi.spyOn(api, 'readFile').mockImplementation(() => new Promise(resolve => { finish = resolve }))
vi.spyOn(api, 'writeFile').mockResolvedValue('r1')
vi.spyOn(drafts, 'delete').mockResolvedValue(undefined)
try {
const first = openDailyNote(), second = openDailyNote()
finish({ content: '# {{date}}\n\n日记', rev: 'template-r1' })
await Promise.all([first, second])
expect(ws.tabs.filter(tab => tab.path === path)).toHaveLength(1)
expect(editorStateOf(path)?.doc.toString()).toContain('日记')
await closeTab(path)
vi.mocked(api.readFile).mockResolvedValue({ content: '重新打开的日记', rev: 'r1' })
await openDailyNote()
expect(ws.tabs.filter(tab => tab.path === path)).toHaveLength(1)
expect(editorStateOf(path)?.doc.toString()).toBe('重新打开的日记')
await closeTab(path)
} finally {
settings.dailyFolder = previousFolder
settings.dailyTemplate = previousTemplate
}
})
})
describe('保存与关闭', () => {
it('关闭冲突标签前持久化冲突之后的新输入,草稿失败时保留标签', async () => {
const v = await edit('conflict.md')
+7 -2
View File
@@ -481,7 +481,11 @@ export function revealPending(v: EditorView, path: string): boolean {
export async function openNote(path: string) {
ws.view = 'notes'
ws.images = null
ws.sidebarOpen = false
// 目录中的新笔记先乐观显示,读取前须等它真正创建成功。
const pending = creating.get(path)
if (pending && !(await pending)) return
if (tabOf(path)) {
ws.active = path
return
@@ -672,6 +676,8 @@ const creating = new Map<string, Promise<boolean>>()
/** 在指定路径新建空笔记并打开,返回是否成功;不存在的上级目录由服务器创建 */
export async function createNoteAt(path: string, content = ''): Promise<boolean> {
ws.view = 'notes'
ws.images = null
const done = createNoteFile(path, content)
creating.set(path, done)
try {
@@ -692,7 +698,6 @@ async function createNoteFile(path: string, content: string): Promise<boolean> {
ws.tabs.push({ path, rev, status: 'ready', dirty: false })
editorStates.set(path, buildState(path, content))
ws.active = path
ws.view = 'notes'
return true
} catch (err) {
ws.entries = ws.entries.filter((e) => !added.includes(e.path))
@@ -1029,7 +1034,7 @@ export async function startWorkspace() {
)
// 字号、正文宽度变化后行高随之改变,编辑器需要重新测量
watch(
() => [settings.fontSize, settings.contentWidth, settings.sidebarWidth, settings.textFont, settings.codeFont, settings.lineHeight, settings.paragraphSpacing],
() => [settings.uiFontSize, settings.fontSize, settings.contentWidth, settings.sidebarWidth, settings.textFont, settings.codeFont, settings.lineHeight, settings.paragraphSpacing],
() => view?.requestMeasure(),
)
watch(
+3 -4
View File
@@ -236,9 +236,8 @@ function render(note: SharedNote) {
const backdrop = action('', '关闭文章大纲', () => root.classList.remove('toc-open'))
backdrop.className = 'rm-share-sidebar-backdrop'
root.append(backdrop)
resolveImages(article)
resolveLinks(article)
resolveLinks(header)
resolveImages(surface)
resolveLinks(surface)
const toc = document.createElement('nav')
toc.className = 'rm-public-toc'
toc.setAttribute('aria-label', '文章大纲')
@@ -259,7 +258,7 @@ function render(note: SharedNote) {
const hint = Object.assign(document.createElement('p'), { className: 'rm-reading-hint', textContent: '当前内容为只读分享,修改由知识库作者在源文件中完成。' })
aside.append(hint)
void sharedNavigation(aside)
hydrate(article)
hydrate(surface)
for (const code of article.querySelectorAll<HTMLElement>('pre > code')) void decorateCode(code).catch(error => {
code.title = '代码高亮失败,已保留源码'
console.error('分享页代码高亮失败', error)
+38 -31
View File
@@ -8,7 +8,7 @@
overflow-wrap: break-word;
}
.rm-share-message { margin-top: 30vh; text-align: center; color: var(--rm-text-muted); }
.rm-share-message { font-size: var(--rm-ui-font-size); margin-top: 30vh; text-align: center; color: var(--rm-text-muted); }
.rm-share-nav { margin-bottom: 16px; color: var(--rm-text-muted); }
.rm-share-nav a, .rm-share-files a { color: var(--rm-accent-strong); }
.rm-share-files { list-style: none; padding: 0; }
@@ -17,27 +17,28 @@
.rm-share-password { display: flex; flex-direction: column; gap: 12px; max-width: 300px; margin: 25vh auto 0; }
.rm-share-password p { margin: 0; }
.rm-share-password input {
height: 40px;
min-width: 0;
height: var(--rm-control-size);
padding: 0 12px;
border: 1px solid var(--rm-divider);
border-radius: 8px;
background: var(--rm-bg);
color: var(--rm-text);
font-size: 15px;
font-size: var(--rm-ui-font-size);
outline: none;
}
.rm-share-password input:focus { border-color: var(--rm-accent); box-shadow: 0 0 0 3px var(--rm-selection); }
.rm-share-password button {
height: 40px;
height: var(--rm-control-size);
border: 0;
border-radius: 8px;
background: var(--rm-accent);
color: #fff;
font-size: 15px;
font-size: var(--rm-ui-font-size);
cursor: pointer;
}
.rm-share-password button:disabled { opacity: 0.6; }
.rm-share-error { min-height: 1.2em; color: #e5484d; font-size: 13px; }
.rm-share-error { min-height: 1.2em; color: #e5484d; font-size: var(--rm-ui-font-small); }
.rm-share-header {
display: flex;
@@ -46,7 +47,7 @@
padding-bottom: 12px;
border-bottom: 1px solid var(--rm-divider);
color: var(--rm-text-muted);
font-size: 13px;
font-size: var(--rm-font-size);
}
.rm-share-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rm-share-time { flex: none; }
@@ -61,6 +62,7 @@
.rm-share-content p { margin: 0.6em 0; }
.rm-share-content a { color: var(--rm-accent-strong); text-decoration: none; }
.rm-share-content a:hover { text-decoration: underline; }
.rm-share-header img,
.rm-share-content img { max-width: 100%; border-radius: 4px; }
.rm-share-content mark { background: var(--rm-highlight-bg); color: inherit; padding: 0 2px; border-radius: 2px; }
.rm-share-content hr { border: 0; border-top: 1px solid var(--rm-divider); margin: 1.5em 0; }
@@ -116,40 +118,45 @@
}
/* 公开阅读工作台:展示当前分享范围,不加载编辑功能。 */
html { scroll-padding-top: 88px; }
html { scroll-padding-top: calc(var(--rm-header-height) + 24px); }
body { background: radial-gradient(ellipse at 25% 0,var(--rm-selection),transparent 55%), var(--rm-bg); }
.rm-share { max-width: none; padding: 100px 32px 72px; font-family: var(--rm-font-editor, var(--rm-font-text)); }
.rm-public-header { position: fixed; inset: 0 0 auto; height: 56px; display: flex; align-items: center; gap: 20px; padding: 0 28px; z-index: 40; border-bottom: 1px solid var(--rm-divider); background: color-mix(in srgb,var(--rm-bg) calc(var(--rm-glass-opacity) * 100%),transparent); backdrop-filter: blur(20px); font-family: var(--rm-font-text); }
.rm-public-brand { color: var(--rm-accent-strong); text-decoration: none; font: 600 24px var(--rm-font-heading); }
.rm-public-badge { font-size: 10px; padding: 4px 8px; border: 1px solid var(--rm-divider); border-radius: 30px; color: var(--rm-text-muted); white-space: nowrap; }
.rm-public-current { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; color: var(--rm-text-muted); }
.rm-share { max-width: none; padding: calc(var(--rm-header-height) + 36px) 32px 72px; font-family: var(--rm-font-editor, var(--rm-font-text)); }
.rm-public-header { box-sizing: border-box; font-size: var(--rm-ui-font-size); line-height: 1.5; position: fixed; inset: 0 0 auto; height: var(--rm-header-height); display: flex; align-items: center; gap: 20px; padding: 0 28px; z-index: 40; border-bottom: 1px solid var(--rm-divider); background: color-mix(in srgb,var(--rm-bg) calc(var(--rm-glass-opacity) * 100%),transparent); backdrop-filter: blur(20px); font-family: var(--rm-font-text); }
.rm-public-brand { color: var(--rm-accent-strong); text-decoration: none; font: 600 var(--rm-ui-font-title) var(--rm-font-heading); }
.rm-public-badge { font-size: var(--rm-ui-font-caption); padding: 4px 8px; border: 1px solid var(--rm-divider); border-radius: 30px; color: var(--rm-text-muted); white-space: nowrap; }
.rm-public-current { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); }
.rm-public-actions { display: flex; gap: 8px; margin-left: auto; }
.rm-public-actions button { border: 1px solid transparent; border-radius: 7px; background: none; color: var(--rm-text-muted); padding: 7px 10px; font: 11px var(--rm-font-text); cursor: pointer; white-space: nowrap; }
.rm-public-actions button { min-height: var(--rm-control-size); border: 1px solid transparent; border-radius: 7px; background: none; color: var(--rm-text-muted); padding: 7px 10px; font: var(--rm-ui-font-size) var(--rm-font-text); cursor: pointer; white-space: nowrap; }
.rm-public-actions button:hover, .rm-public-actions button[aria-pressed=true] { background: var(--rm-hover); color: var(--rm-accent); } .rm-public-actions button:nth-last-child(2) { border-color: var(--rm-divider); }
.rm-share > .rm-share-nav { max-width: 1100px; margin: -16px auto 24px; font: 10px var(--rm-font-text); }
.rm-share > .rm-share-nav { max-width: 1100px; margin: -16px auto 24px; font: var(--rm-ui-font-caption) var(--rm-font-text); }
.rm-reading-layout { display: flex; align-items: flex-start; justify-content: center; gap: 32px; max-width: 1140px; margin: auto; }
.rm-share-sidebar { position: sticky; top: 88px; width: 240px; flex: none; max-height: calc(100dvh - 120px); overflow: auto; font-family: var(--rm-font-text); }
.rm-share-sidebar { position: sticky; top: calc(var(--rm-header-height) + 24px); width: 240px; flex: none; max-height: calc(100dvh - 120px); overflow: auto; font-family: var(--rm-font-text); }
.rm-public-toc, .rm-public-file-nav { padding: 20px 14px; border: 1px solid var(--rm-divider); border-radius: 13px; background: color-mix(in srgb,var(--rm-sidebar-bg) calc(var(--rm-glass-opacity) * 100%),transparent); backdrop-filter: blur(18px); margin-bottom: 16px; }
.rm-share-sidebar h2 { font-size: 12px; margin: 0 10px 16px; color: var(--rm-accent); font-weight: 600; }
.rm-share-sidebar a { display: block; padding: 8px 12px; text-decoration: none; color: var(--rm-text-muted); font-size: 11px; line-height: 1.65; border-radius: 6px; overflow-wrap: anywhere; }
.rm-share-sidebar h2 { font-size: var(--rm-ui-font-size); margin: 0 10px 16px; color: var(--rm-accent); font-weight: 600; }
.rm-share-sidebar a { display: block; padding: 8px 12px; text-decoration: none; color: var(--rm-text-muted); font-size: var(--rm-ui-font-size); line-height: 1.65; border-radius: 6px; overflow-wrap: anywhere; }
.rm-share-sidebar a:hover { background: var(--rm-hover); } .rm-share-sidebar a.on { color: var(--rm-accent); background: var(--rm-active); box-shadow: inset 2px 0 var(--rm-accent); }
.rm-reading-progress-label { font-size: 10px; color: var(--rm-text-muted); border-top: 1px solid var(--rm-divider); padding-top: 18px; margin-top: 20px; } progress { display: block; width: 100%; height: 5px; margin-top: 9px; border: 0; border-radius: 6px; overflow: hidden; accent-color: var(--rm-accent); } progress::-webkit-progress-bar { background: var(--rm-divider); } progress::-webkit-progress-value { background: var(--rm-accent); } progress::-moz-progress-bar { background: var(--rm-accent); }
.rm-reading-hint { border: 1px solid var(--rm-divider); border-radius: 12px; padding: 18px; font-size: 10px; line-height: 1.9; color: var(--rm-text-muted); background: var(--rm-active); }
.rm-reading-progress-label { font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); border-top: 1px solid var(--rm-divider); padding-top: 18px; margin-top: 20px; } progress { display: block; width: 100%; height: 5px; margin-top: 9px; border: 0; border-radius: 6px; overflow: hidden; accent-color: var(--rm-accent); } progress::-webkit-progress-bar { background: var(--rm-divider); } progress::-webkit-progress-value { background: var(--rm-accent); } progress::-moz-progress-bar { background: var(--rm-accent); }
.rm-reading-hint { border: 1px solid var(--rm-divider); border-radius: 12px; padding: 18px; font-size: var(--rm-ui-font-caption); line-height: 1.9; color: var(--rm-text-muted); background: var(--rm-active); }
.rm-reading-card { flex: 1; min-width: 0; max-width: var(--rm-content-width); padding: 40px 44px; box-sizing: border-box; border: 1px solid var(--rm-divider); border-radius: 18px; background: color-mix(in srgb,var(--rm-bg) 90%,transparent); box-shadow: 0 8px 40px rgba(19,27,46,.03); }
.rm-share-header { display: block; padding-bottom: 28px; margin-bottom: 28px; border-bottom: 1px solid var(--rm-divider); }
.rm-share-kicker { display: inline-block; font: 9px var(--rm-font-text); letter-spacing: .1em; color: var(--rm-accent); background: var(--rm-selection); padding: 5px 8px; border-radius: 5px; }
.rm-share-kicker { display: inline-block; font: var(--rm-ui-font-caption) var(--rm-font-text); letter-spacing: .1em; color: var(--rm-accent); background: var(--rm-selection); padding: 5px 8px; border-radius: 5px; }
.rm-share-header h1 { font: 600 2.125em/1.5 var(--rm-font-heading); color: var(--rm-text); margin: 20px 0 18px; }
.rm-share-time { display: block; font: 10px/1.8 var(--rm-font-text); color: var(--rm-text-muted); margin: 0; }
.rm-share-content :is(h1,h2,h3,h4,h5,h6) { font-family: var(--rm-font-heading); font-weight: 600; scroll-margin-top: 80px; } .rm-share-content h2 { font-size: 1.5em; line-height: 1.5; margin-top: 1.5em; } .rm-share-content p { margin: 0 0 var(--rm-paragraph-spacing); } .rm-share-content blockquote { border-left-color: var(--rm-accent); background: color-mix(in srgb,var(--rm-accent) 5%,transparent); border-radius: 0 8px 8px 0; padding: 16px 20px; } .rm-share-content blockquote p:last-child { margin-bottom: 0; }
.rm-share-content pre { position: relative; border: 1px solid var(--rm-divider); border-radius: 12px; padding: 42px 20px 20px; }
.rm-public-code-tools { position: absolute; inset: 8px 12px auto; display: flex; justify-content: space-between; align-items: center; font: 10px var(--rm-font-mono); color: var(--rm-text-muted); user-select: none; }
.rm-public-code-tools button { color: inherit; font: inherit; background: none; border: 0; padding: 4px 8px; border-radius: 5px; cursor: pointer; } .rm-public-code-tools button:hover { background: var(--rm-hover); color: var(--rm-accent); }
.rm-reading-footer { margin-top: 40px; border-top: 1px solid var(--rm-divider); padding-top: 24px; color: var(--rm-text-muted); font: 10px var(--rm-font-text); text-align: center; }
.rm-share-time { display: block; font: var(--rm-ui-font-small)/1.8 var(--rm-font-text); color: var(--rm-text-muted); margin: 0; }
.rm-share-content :is(h1,h2,h3,h4,h5,h6) { font-family: var(--rm-font-heading); font-weight: 600; scroll-margin-top: var(--rm-header-height); } .rm-share-content h2 { font-size: 1.5em; line-height: 1.5; margin-top: 1.5em; } .rm-share-content p { margin: 0 0 var(--rm-paragraph-spacing); } .rm-share-content blockquote { border-left-color: var(--rm-accent); background: color-mix(in srgb,var(--rm-accent) 5%,transparent); border-radius: 0 8px 8px 0; padding: 16px 20px; } .rm-share-content blockquote p:last-child { margin-bottom: 0; }
.rm-share-content pre { position: relative; border: 1px solid var(--rm-divider); border-radius: 12px; padding: calc(var(--rm-control-compact) + 20px) 20px 20px; }
.rm-public-code-tools { position: absolute; inset: 8px 12px auto; display: flex; justify-content: space-between; align-items: center; font: var(--rm-ui-font-caption) var(--rm-font-mono); color: var(--rm-text-muted); user-select: none; }
.rm-public-code-tools button { min-height: var(--rm-control-compact); color: inherit; font: inherit; background: none; border: 0; padding: 4px 8px; border-radius: 5px; cursor: pointer; } .rm-public-code-tools button:hover { background: var(--rm-hover); color: var(--rm-accent); }
.rm-reading-footer { margin-top: 40px; border-top: 1px solid var(--rm-divider); padding-top: 24px; color: var(--rm-text-muted); font: var(--rm-ui-font-small) var(--rm-font-text); text-align: center; }
.rm-share-sidebar-backdrop { display: none; }
.rm-share-focus .rm-share-sidebar { display: none; } .rm-share-focus .rm-reading-layout { max-width: 960px; } .rm-share-focus .rm-reading-card { box-shadow: none; }
.rm-share-listing { max-width: 900px; margin: auto; border: 1px solid var(--rm-divider); border-radius: 18px; padding: 36px; background: var(--rm-active); } .rm-share-listing h1 { font: 600 28px var(--rm-font-heading); } .rm-share-listing .rm-share-nav { font-size: 11px; } .rm-share-files li { padding: 10px; border-bottom: 1px solid var(--rm-divider); font-size: 13px; } .rm-share-files a { text-decoration: none; } .rm-share:has(.rm-share-listing) .rm-reading-action { display: none; }
.rm-share-password { max-width: 340px; padding: 32px; border: 1px solid var(--rm-divider); border-radius: 18px; background: var(--rm-active); box-shadow: var(--rm-shadow); font-family: var(--rm-font-text); }
.rm-share-listing { font-size: var(--rm-ui-font-size); max-width: 900px; margin: auto; border: 1px solid var(--rm-divider); border-radius: 18px; padding: 36px; background: var(--rm-active); } .rm-share-listing h1 { font: 600 var(--rm-ui-font-title) var(--rm-font-heading); } .rm-share-listing .rm-share-nav { font-size: var(--rm-ui-font-size); } .rm-share-files li { padding: 10px; border-bottom: 1px solid var(--rm-divider); font-size: var(--rm-ui-font-size); } .rm-share-files a { text-decoration: none; } .rm-share:has(.rm-share-listing) .rm-reading-action { display: none; }
.rm-share-password { font-size: var(--rm-ui-font-size); box-sizing: border-box; width: 100%; max-width: 340px; padding: 32px; border: 1px solid var(--rm-divider); border-radius: 18px; background: var(--rm-active); box-shadow: var(--rm-shadow); font-family: var(--rm-font-text); }
@media(min-width:1001px) { .rm-public-actions .rm-toc-toggle { display: none; } }
@media(max-width:1000px) { .rm-share { padding: 84px 24px 48px; } .rm-share-sidebar { display: none; } .rm-public-current { display: none; } .toc-open .rm-share-sidebar { display: block; position: fixed; inset: 56px auto 0 0; width: 260px; max-height: none; z-index: 35; padding: 20px; background: var(--rm-bg); box-shadow: var(--rm-shadow); } .toc-open .rm-share-sidebar-backdrop { display: block; position: fixed; inset: 56px 0 0; border: 0; background: rgba(19,27,46,.2); z-index: 34; } }
@media(max-width:600px) { .rm-share { padding: 76px 12px 40px; } .rm-public-header { height: 56px; padding: env(safe-area-inset-top) 12px 0; gap: 8px; } .rm-public-brand { font-size: 20px; } .rm-public-badge { display: none; } .rm-public-actions { gap: 0; } .rm-public-actions button { min-height: 44px; font-size: 10px; padding: 5px 7px; } .rm-reading-card { padding: 24px 20px; border-radius: 14px; } .rm-share-header h1 { font-size: 1.75em; } .rm-share-time { display: block; font-size: 9px; } .rm-share-content pre { padding: 42px 16px 16px; } .rm-public-code-tools { color: #b9c2d6; } .rm-share-listing { padding: 24px 16px; } }
@media(max-width:1000px) { .rm-share { padding: calc(var(--rm-header-height) + 24px) 24px 48px; } .rm-share-sidebar { display: none; } .rm-public-current { display: none; } .toc-open .rm-share-sidebar { display: block; position: fixed; inset: var(--rm-header-height) auto 0 0; width: min(84vw, 300px); box-sizing: border-box; max-height: none; z-index: 35; padding: 20px; background: var(--rm-bg); box-shadow: var(--rm-shadow); } .toc-open .rm-share-sidebar-backdrop { display: block; position: fixed; inset: var(--rm-header-height) 0 0; border: 0; background: rgba(19,27,46,.2); z-index: 34; } }
@media(max-width:767px) { :root { --rm-header-height: calc(var(--rm-control-size) + var(--rm-ui-font-size) * 1.5 + 20px); } .rm-public-header { flex-wrap: wrap; align-content: center; padding: max(8px, env(safe-area-inset-top)) 12px 8px; gap: 4px 8px; } .rm-public-brand { font-size: var(--rm-ui-font-heading); } .rm-public-badge { display: none; } .rm-public-actions { width: 100%; justify-content: space-between; margin-left: 0; gap: 0; } .rm-public-actions button { min-width: 44px; min-height: 44px; font-size: var(--rm-ui-font-size); padding: 5px 4px; } }
@media(max-width:600px) { .rm-share { padding: calc(var(--rm-header-height) + 20px) 12px 40px; } .rm-reading-card { padding: 24px 20px; border-radius: 14px; } .rm-share-header h1 { font-size: 1.75em; } .rm-share-time { display: block; font-size: var(--rm-ui-font-small); } .rm-share-content pre { padding: calc(var(--rm-control-compact) + 20px) 16px 16px; } .rm-public-code-tools { color: #b9c2d6; } .rm-share-listing { padding: 24px 16px; } }
@media print { body { background: white; color: black; } .rm-share { padding: 0; } .rm-public-header, .rm-share-sidebar, .rm-share-nav, .rm-share-sidebar-backdrop, .rm-public-code-tools { display: none !important; } .rm-reading-layout { display: block; max-width: none; } .rm-reading-card, .rm-share-listing { border: 0; box-shadow: none; max-width: none; padding: 0; background: white; } .rm-share-content pre, .rm-share-content table { overflow: visible; } .rm-share-content pre { white-space: pre-wrap; padding: 16px; } a { color: inherit; } }
@media (max-width: 767px), (pointer: coarse) {
.rm-share-password input { font-size: max(16px, var(--rm-ui-font-size)); }
}
+30 -11
View File
@@ -9,6 +9,16 @@
--rm-font-heading: 'Noto Serif SC', 'Source Han Serif SC', serif;
--rm-font-mono: 'JetBrains Mono', ui-monospace, monospace;
--rm-font-size: 16px;
/* 界面排版独立于正文;辅助文字随界面缩放,但不小于 13px。 */
--rm-ui-font-size: 16px;
--rm-ui-font-small: max(13px, calc(var(--rm-ui-font-size) - 2px));
--rm-ui-font-caption: max(13px, calc(var(--rm-ui-font-size) - 3px));
--rm-ui-font-heading: calc(var(--rm-ui-font-size) + 4px);
--rm-ui-font-title: calc(var(--rm-ui-font-size) + 12px);
--rm-icon-size: calc(var(--rm-ui-font-size) + 4px);
--rm-control-size: max(40px, calc(var(--rm-ui-font-size) + 24px));
--rm-control-compact: max(32px, calc(var(--rm-ui-font-size) + 16px));
--rm-header-height: calc(var(--rm-ui-font-size) + 48px);
--rm-line-height: 1.75;
--rm-content-width: 760px;
--rm-paragraph-spacing: 16px;
@@ -89,6 +99,14 @@ body {
font-family: var(--rm-font-text);
}
body { font-size: var(--rm-ui-font-size); line-height: 1.5; }
button, input, select, textarea { font: inherit; }
.rm-icon { flex: none; width: var(--rm-icon-size); height: var(--rm-icon-size); }
@media (pointer: coarse), (max-width: 767px) {
:root { --rm-control-size: max(44px, calc(var(--rm-ui-font-size) + 24px)); --rm-control-compact: max(44px, calc(var(--rm-ui-font-size) + 16px)); }
input:not([type=checkbox],[type=radio],[type=range]), select, textarea { font-size: max(16px, var(--rm-ui-font-size)); }
}
.cm-editor {
font-size: var(--rm-font-size);
line-height: var(--rm-line-height);
@@ -160,7 +178,7 @@ body {
pointer-events: none;
}
.cm-line.cm-rm-code .cm-rm-mono { font-family: inherit; }
.cm-line.cm-rm-code-begin { padding-top: 10px; min-height: 26px; }
.cm-line.cm-rm-code-begin { padding-top: 10px; min-height: var(--rm-control-compact); }
.cm-line.cm-rm-code-end { padding-bottom: 10px; }
.cm-line.cm-rm-code-begin::before { border-radius: 8px 8px 0 0; }
.cm-line.cm-rm-code-end::before { border-radius: 0 0 8px 8px; }
@@ -168,13 +186,13 @@ body {
/* 围栏展开前后保持同一行高,语言标签与复制按钮不参与行高计算,避免代码内容跳动。 */
.cm-rm-code-header { display: inline-flex; align-items: center; gap: 6px; vertical-align: middle; user-select: none; }
.cm-line.cm-rm-code-begin .cm-rm-code-header { position: absolute; top: 6px; right: 12px; }
.cm-rm-code-lang { color: var(--rm-text-muted); font-size: 12px; line-height: 1; }
.cm-rm-code-lang { color: var(--rm-text-muted); font-size: var(--rm-ui-font-small); line-height: 1; }
.cm-rm-code-copy {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
width: var(--rm-control-compact);
height: var(--rm-control-compact);
padding: 0;
border: 0;
border-radius: 6px;
@@ -235,6 +253,8 @@ body {
.cm-rm-table-widget:hover .cm-rm-table-toolbar,
.cm-rm-table-widget:focus-within .cm-rm-table-toolbar { visibility: visible; opacity: 1; }
.cm-rm-table-tools {
max-width: 100%;
flex-wrap: wrap;
display: inline-flex;
align-items: center;
padding: 2px;
@@ -244,22 +264,24 @@ body {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.cm-rm-table-tools button {
min-width: var(--rm-control-compact);
display: inline-flex;
align-items: center;
gap: 4px;
height: 26px;
height: var(--rm-control-compact);
padding: 0 8px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--rm-text-muted);
font: 12px/1 var(--rm-font-text);
font: var(--rm-ui-font-small)/1 var(--rm-font-text);
cursor: pointer;
transition: color 0.15s, background-color 0.15s;
}
.cm-rm-table-tools button:hover { color: var(--rm-accent-strong); background: var(--rm-selection); }
.cm-rm-table-tools button.cm-rm-danger:hover { color: #e5484d; background: rgba(229, 72, 77, 0.1); }
.cm-rm-table-tools svg { flex: none; }
.cm-rm-table-tools svg { flex: none; width: var(--rm-ui-font-size); height: var(--rm-ui-font-size); }
.cm-rm-code-copy svg { width: var(--rm-icon-size); height: var(--rm-icon-size); }
.cm-rm-table-tools .cm-rm-sep { width: 1px; height: 16px; margin: 0 2px; background: var(--rm-divider); }
.cm-rm-image { cursor: pointer; }
@@ -267,10 +289,7 @@ body {
.cm-editor .cm-content { padding: 16px 16px 40vh; }
/* 触屏没有悬停,工具栏常显,按钮加大触控热区 */
.cm-rm-table-toolbar { visibility: visible; opacity: 1; }
.cm-rm-table-tools button { height: 36px; padding: 0 10px; }
.cm-rm-code-copy { width: 36px; height: 36px; }
.cm-line.cm-rm-code { padding-left: 12px; padding-right: 12px; }
.cm-line.cm-rm-code-begin { min-height: 36px; }
}
/* ---- Typora 扩展语法 ---- */
@@ -297,7 +316,7 @@ body {
background: rgba(229, 72, 77, 0.08);
color: #e5484d;
font-family: var(--rm-font-mono);
font-size: 12px;
font-size: var(--rm-ui-font-small);
text-align: left;
white-space: pre-wrap;
}
+33
View File
@@ -0,0 +1,33 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it } from 'vitest'
import { applyDisplaySettings, DISPLAY_DEFAULTS, sanitizeDisplaySettings } from './typography'
afterEach(() => document.documentElement.removeAttribute('style'))
describe('界面与正文排版设置', () => {
it('旧设置缺少界面字号时使用 16px,保留正文字号', () => {
expect(sanitizeDisplaySettings({ fontSize: 24 })).toMatchObject({ uiFontSize: 16, fontSize: 24 })
})
it.each([14, 16, 20])('接受范围内的整数界面字号 %s', uiFontSize => {
expect(sanitizeDisplaySettings({ uiFontSize }).uiFontSize).toBe(uiFontSize)
})
it.each([13, 21, 16.5, '18', null, Number.NaN, Number.POSITIVE_INFINITY])('拒绝非法界面字号 %s', uiFontSize => {
expect(sanitizeDisplaySettings({ uiFontSize }).uiFontSize).toBe(16)
})
it('应用界面字号不改变正文、代码字体和正文宽度', () => {
const display = sanitizeDisplaySettings({ uiFontSize: 20, fontSize: 24, contentWidth: 960, textFont: 'serif', codeFont: 'system' })
applyDisplaySettings(display)
const style = document.documentElement.style
expect(style.getPropertyValue('--rm-ui-font-size')).toBe('20px')
expect(style.getPropertyValue('--rm-font-size')).toBe('24px')
expect(style.getPropertyValue('--rm-content-width')).toBe('960px')
expect(style.getPropertyValue('--rm-font-editor')).toContain('Noto Serif SC')
expect(style.getPropertyValue('--rm-font-mono')).toBe('ui-monospace, monospace')
applyDisplaySettings({ ...display, uiFontSize: DISPLAY_DEFAULTS.uiFontSize })
expect(style.getPropertyValue('--rm-ui-font-size')).toBe('16px')
expect(style.getPropertyValue('--rm-font-size')).toBe('24px')
})
})
+4 -1
View File
@@ -8,11 +8,13 @@ export const CODE_FONTS = {
jetbrains: { label: 'JetBrains Mono', css: "'JetBrains Mono', ui-monospace, monospace" },
system: { label: '系统等宽字体', css: 'ui-monospace, monospace' },
}
export const DISPLAY_DEFAULTS = { fontSize: 16, contentWidth: 760, textFont: 'sans' as keyof typeof TEXT_FONTS, codeFont: 'jetbrains' as keyof typeof CODE_FONTS, lineHeight: 1.75, paragraphSpacing: 16, glassOpacity: .75 }
export const UI_FONT_SIZE = { min: 14, max: 20, default: 16 }
export const DISPLAY_DEFAULTS = { uiFontSize: UI_FONT_SIZE.default, fontSize: 16, contentWidth: 760, textFont: 'sans' as keyof typeof TEXT_FONTS, codeFont: 'jetbrains' as keyof typeof CODE_FONTS, lineHeight: 1.75, paragraphSpacing: 16, glassOpacity: .75 }
export type DisplaySettings = typeof DISPLAY_DEFAULTS
export function sanitizeDisplaySettings(raw: Record<string, unknown>): DisplaySettings {
const ranged = (key: 'lineHeight' | 'paragraphSpacing' | 'glassOpacity', min: number, max: number) => typeof raw[key] === 'number' && Number.isFinite(raw[key]) && raw[key] >= min && raw[key] <= max ? raw[key] : DISPLAY_DEFAULTS[key]
return {
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 === 'serif' || raw.textFont === 'system' ? raw.textFont : 'sans',
@@ -22,6 +24,7 @@ export function sanitizeDisplaySettings(raw: Record<string, unknown>): DisplaySe
}
export function applyDisplaySettings(settings: DisplaySettings) {
const root = document.documentElement.style
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)