From a716794e41c5283e10fe06d140c5ef2d64cbea60 Mon Sep 17 00:00:00 2001 From: eezd Date: Fri, 23 Oct 2026 10:55:53 +0800 Subject: [PATCH] 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. --- README.md | 2 +- internal/api/openapi.json | 7 + internal/api/themes.go | 2 +- internal/api/themes_test.go | 4 +- internal/search/search.go | 4 +- internal/search/search_test.go | 19 ++ internal/vault/cleanup_test.go | 30 +++ internal/vault/metadata.go | 2 +- internal/vault/metadata_test.go | 24 +++ internal/vault/vault.go | 2 +- web/src/app/Icon.vue | 5 +- web/src/app/ImageDialog.vue | 8 +- web/src/app/ImageGallery.vue | 21 ++- web/src/app/ImportDialog.vue | 5 +- web/src/app/KnowledgePanel.vue | 13 +- web/src/app/MobileToolbar.vue | 19 +- web/src/app/NoteRelations.vue | 8 +- web/src/app/SettingsDialog.vue | 26 ++- web/src/app/ShortcutSettings.vue | 2 +- web/src/app/TabBar.vue | 4 +- web/src/app/TemplateDialog.vue | 3 +- web/src/app/TreeNode.vue | 40 +++- web/src/app/app.css | 301 ++++++++++++++++-------------- web/src/app/templates.ts | 13 +- web/src/app/workspace.test.ts | 74 +++++++- web/src/app/workspace.ts | 9 +- web/src/share/main.ts | 7 +- web/src/share/share.css | 69 ++++--- web/src/themes/base.css | 41 ++-- web/src/themes/typography.test.ts | 33 ++++ web/src/themes/typography.ts | 5 +- 31 files changed, 556 insertions(+), 246 deletions(-) create mode 100644 web/src/themes/typography.test.ts diff --git a/README.md b/README.md index 205355e..ef0ff83 100644 --- a/README.md +++ b/README.md @@ -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`)。 diff --git a/internal/api/openapi.json b/internal/api/openapi.json index 7058ccf..00895ce 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -1810,6 +1810,13 @@ "dark" ] }, + "uiFontSize": { + "type": "integer", + "minimum": 14, + "maximum": 20, + "default": 16, + "description": "界面字号,与正文排版字号独立" + }, "fontSize": { "type": "integer" }, diff --git a/internal/api/themes.go b/internal/api/themes.go index 458dd9f..be61e17 100644 --- a/internal/api/themes.go +++ b/internal/api/themes.go @@ -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 } diff --git a/internal/api/themes_test.go b/internal/api/themes_test.go index d06d60c..8b3193c 100644 --- a/internal/api/themes_test.go +++ b/internal/api/themes_test.go @@ -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 { diff --git a/internal/search/search.go b/internal/search/search.go index 5308a3c..dccf34d 100644 --- a/internal/search/search.go +++ b/internal/search/search.go @@ -19,8 +19,8 @@ import ( "rawmark/internal/vault" ) -// 修改表结构时递增,旧索引会被丢弃重建 -const schemaVersion = 2 +// 表结构或派生元数据的解析规则变化时递增,旧索引会被丢弃重建。 +const schemaVersion = 3 const schema = ` CREATE TABLE docs ( diff --git a/internal/search/search_test.go b/internal/search/search_test.go index e3a3c70..309933f 100644 --- a/internal/search/search_test.go +++ b/internal/search/search_test.go @@ -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) + } +} diff --git a/internal/vault/cleanup_test.go b/internal/vault/cleanup_test.go index ded64a3..b84a621 100644 --- a/internal/vault/cleanup_test.go +++ b/internal/vault/cleanup_test.go @@ -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) + } + }) + } +} diff --git a/internal/vault/metadata.go b/internal/vault/metadata.go index ba2ea68..8329334 100644 --- a/internal/vault/metadata.go +++ b/internal/vault/metadata.go @@ -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 diff --git a/internal/vault/metadata_test.go b/internal/vault/metadata_test.go index b543a0b..0293bec 100644 --- a/internal/vault/metadata_test.go +++ b/internal/vault/metadata_test.go @@ -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) + } +} diff --git a/internal/vault/vault.go b/internal/vault/vault.go index 4ee570c..ff16a68 100644 --- a/internal/vault/vault.go +++ b/internal/vault/vault.go @@ -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") diff --git a/web/src/app/Icon.vue b/web/src/app/Icon.vue index 6300f57..4361d85 100644 --- a/web/src/app/Icon.vue +++ b/web/src/app/Icon.vue @@ -44,8 +44,9 @@ defineProps<{ name: IconName; size?: number }>() diff --git a/web/src/app/ImportDialog.vue b/web/src/app/ImportDialog.vue index 0bed2d8..6831a44 100644 --- a/web/src/app/ImportDialog.vue +++ b/web/src/app/ImportDialog.vue @@ -102,13 +102,14 @@ async function submit() { diff --git a/web/src/app/KnowledgePanel.vue b/web/src/app/KnowledgePanel.vue index d4edfca..1dbf932 100644 --- a/web/src/app/KnowledgePanel.vue +++ b/web/src/app/KnowledgePanel.vue @@ -75,10 +75,11 @@ watch(() => ws.view, () => { query.value = ''; tag.value = '' }) diff --git a/web/src/app/MobileToolbar.vue b/web/src/app/MobileToolbar.vue index a0aaab8..55b5d81 100644 --- a/web/src/app/MobileToolbar.vue +++ b/web/src/app/MobileToolbar.vue @@ -1,7 +1,7 @@ diff --git a/web/src/app/NoteRelations.vue b/web/src/app/NoteRelations.vue index 8b46071..c4f4ab9 100644 --- a/web/src/app/NoteRelations.vue +++ b/web/src/app/NoteRelations.vue @@ -18,9 +18,9 @@ const note = computed(() => metadata.notes.find(note => note.path === ws.active) diff --git a/web/src/app/SettingsDialog.vue b/web/src/app/SettingsDialog.vue index c25c60a..57500cf 100644 --- a/web/src/app/SettingsDialog.vue +++ b/web/src/app/SettingsDialog.vue @@ -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() {
diff --git a/web/src/app/TreeNode.vue b/web/src/app/TreeNode.vue index 208b745..089038b 100644 --- a/web/src/app/TreeNode.vue +++ b/web/src/app/TreeNode.vue @@ -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() +const menuElement = ref() +const menuPosition = ref({ left: 0, top: 0 }) const dropTarget = ref(false) const input = ref() 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) {