From 4d1339851b143c28bc96be8de130ceea871fd98a Mon Sep 17 00:00:00 2001 From: eezd Date: Thu, 22 Oct 2026 15:43:52 +0800 Subject: [PATCH] feat(ui): redesign public reading and complete glass workspace Add scoped public navigation, reading progress, focus and print controls without loading the editor. Polish responsive settings, graph and media layouts. Correct share expiry units, preserve math and code during link maintenance, and normalize Markdown destinations for templates and note navigation. Verify browser flows and document the new tools. --- README.md | 7 +- internal/api/assets.go | 9 +- internal/api/assets_test.go | 35 +++++++ internal/api/openapi.json | 5 +- internal/vault/images.go | 11 +++ internal/vault/links.go | 29 +++++- internal/vault/metadata.go | 22 +++-- internal/vault/metadata_math.go | 104 ++++++++++++++++++++ internal/vault/metadata_test.go | 20 ++++ web/src/app/AppHeader.vue | 2 +- web/src/app/AppearanceSettings.vue | 2 + web/src/app/EditorPane.vue | 5 +- web/src/app/ImageGallery.vue | 4 +- web/src/app/KnowledgePanel.vue | 2 +- web/src/app/SettingsDialog.vue | 4 +- web/src/app/SharePanel.vue | 2 +- web/src/app/TemplateDialog.vue | 2 +- web/src/app/TokensPanel.vue | 2 +- web/src/app/app.css | 12 ++- web/src/app/templates.ts | 4 +- web/src/app/workspace.ts | 7 +- web/src/clipboard.ts | 19 ++++ web/src/editor/widgets.ts | 21 +--- web/src/markdown/links.ts | 8 ++ web/src/markdown/template.test.ts | 6 ++ web/src/markdown/template.ts | 5 +- web/src/share/main.ts | 150 ++++++++++++++++++++++++++--- web/src/share/share.css | 39 ++++++++ 28 files changed, 472 insertions(+), 66 deletions(-) create mode 100644 internal/api/assets_test.go create mode 100644 internal/vault/metadata_math.go create mode 100644 web/src/clipboard.ts create mode 100644 web/src/markdown/links.ts diff --git a/README.md b/README.md index f49c084..205355e 100644 --- a/README.md +++ b/README.md @@ -31,10 +31,13 @@ docker run -d --name rawmark \ - 首次打开直接设置登录密码,之后凭密码登录;请在将新实例开放给他人访问前完成初始化。 - 笔记以普通 `.md` 文件保存在 `数据目录/vault/`,图片按 Typora 习惯存放在同级的 `笔记名.assets/`,可直接用 Typora 等编辑器打开;外部修改会实时同步到网页。 -- 图片管理:侧栏默认隐藏 `assets` 和 `笔记名.assets`;右键文件夹选择「图片管理」,查看当前目录及直属附件目录的图片,支持预览、重命名、移动、替换与移入回收站。根目录可在侧栏空白处右键或通过命令面板打开。图片改名/移动会更新笔记中的本地引用;替换保留路径,选择相同扩展名的图片(最大 50 MB),旧图片不进回收站。 +- 知识工具:标准 Markdown 链接构成图谱和反向链接,支持正文 `#标签` 与 YAML `tags`。笔记或文件夹在应用内改名、移动时维护入链和相对出链;公式和代码示例不参与关系提取。 +- 日记与模板:默认日记路径为 `Daily/YYYY-MM-DD.md`(本地日期),已有日记直接打开;模板为 `Templates/` 下的普通 Markdown,支持 `{{date}}`、`{{time}}`、`{{title}}`,资源链接按新笔记位置调整。可在设置中修改目录和日记模板。 +- 外观:浅色玻璃工作台与深色模式,字体资源随应用本地提供;排版、玻璃透明度、选中文本工具栏均可在设置中调整并即时保存。 +- 图片管理:侧栏默认隐藏 `assets` 和 `笔记名.assets`;右键文件夹选择「图片管理」,查看当前目录及直属附件目录的图片,支持检索、排序、网格/列表、批量上传、引用查看及预览、重命名、移动、替换。可切换整个知识库;清理孤立图片须确认候选,服务端再次核对全库引用与有效分享后移入回收站,可恢复。上传至所选目录的 `assets/`,相同内容自动去重。根目录可在侧栏空白处右键或通过命令面板打开。图片改名/移动会更新笔记中的本地引用;替换保留路径,选择相同扩展名的图片(最大 50 MB),旧图片不进回收站。 - `--user` 让容器以宿主机用户身份读写数据目录;不指定时容器以 UID 65532 运行,需要该用户对目录有写权限。 - 请通过 HTTPS 反向代理(Caddy、Nginx 等)访问,并转发 WebSocket(`/api/v1/events`)。 -- 分享:文件或文件夹右键「分享」,也可通过命令面板(F1)分享当前笔记;多选生成一个合集链接。链接 `/s/…` 的标识完全随机,展示源文件最新内容并跟随分享主体移动;文件夹递归包含当前内容。可设密码与有效期,在设置中管理与撤销。 +- 分享:文件或文件夹右键「分享」,也可通过命令面板(F1)分享当前笔记;多选生成一个合集链接。链接 `/s/…` 的标识完全随机,展示源文件最新内容并跟随分享主体移动;文件夹递归包含当前内容。可设密码与有效期,在设置中管理与撤销。公开阅读页仅提供分享范围内的导航、大纲、阅读进度、复制与打印,不加载编辑器。 - 多选与导出:Ctrl/Cmd 点击切换选择,Shift 点击选择可见范围;右键批量分享、删除、移动或导出 Markdown。单篇无附件直接下载 `.md`,其他情况下载包含 Markdown 与本地附件的 ZIP;重名目标不会被覆盖,批量操作会汇报单项失败。 - 快捷键:设置中支持添加、逐条移除和单项/全部重置,并拒绝冲突绑定。默认 Ctrl+Alt+1~6 设置对应级别标题,同级再次触发恢复正文;字号可直接输入。 - 导入:侧栏顶部或文件夹右键菜单选择「导入」,支持多选 UTF-8 `.md` 文件或单个 ZIP(总上传上限 100 MB)。ZIP 新建同名文件夹并保留层级,本地图片转换为笔记独立附件,原附件保留;同名内容不覆盖,缺失的本地链接会提示,外部图片不会自动下载。 diff --git a/internal/api/assets.go b/internal/api/assets.go index de7227b..8c3218a 100644 --- a/internal/api/assets.go +++ b/internal/api/assets.go @@ -1,7 +1,9 @@ package api import ( + "errors" "net/http" + "rawmark/internal/vault" "time" ) @@ -22,12 +24,17 @@ func (s *Server) cleanupImages(w http.ResponseWriter, r *http.Request) { return nil, err } scopes := []string{} - now := time.Now().Unix() + now := time.Now().UnixMilli() for _, share := range shares { if share.ExpiresAt != 0 && share.ExpiresAt <= now { continue } for _, item := range share.Items { + if _, err := s.vault.Stat(item.Path); errors.Is(err, vault.ErrNotFound) { + continue + } else if err != nil { + return nil, err + } scopes = append(scopes, item.Path) } } diff --git a/internal/api/assets_test.go b/internal/api/assets_test.go new file mode 100644 index 0000000..53d24f3 --- /dev/null +++ b/internal/api/assets_test.go @@ -0,0 +1,35 @@ +package api + +import ( + "net/http" + "rawmark/internal/store" + "rawmark/internal/vault" + "testing" + "time" +) + +func TestImageCleanupHonorsShareExpiryAndReferences(t *testing.T) { + f := newFixture(t) + f.write("assets/active.png", "active") + f.write("assets/expired.png", "expired") + f.write("assets/used.png", "used") + f.write("note.md", "[image](assets/used.png)") + for _, share := range []store.Share{ + {Token: "active", Items: []store.ShareItem{{ID: 0, Path: "assets/active.png"}}, ExpiresAt: time.Now().Add(time.Hour).UnixMilli()}, + {Token: "expired", Items: []store.ShareItem{{ID: 0, Path: "assets/expired.png"}}, ExpiresAt: time.Now().Add(-time.Hour).UnixMilli()}, + } { + if err := f.store.CreateShare(share); err != nil { + t.Fatal(err) + } + } + if code := f.status(newClient(), "POST", "/api/v1/assets/cleanup", `{"paths":["assets/expired.png"]}`, nil); code != http.StatusUnauthorized { + t.Fatal(code) + } + result := decode[vault.CleanupResult](t, f.do(f.admin, "POST", "/api/v1/assets/cleanup", `{"paths":["assets/active.png","assets/expired.png","assets/used.png"]}`, nil)) + if len(result.Deleted) != 1 || result.Deleted[0].Path != "assets/expired.png" || len(result.Skipped) != 2 { + t.Fatalf("引用或分享保护不符:%+v", result) + } + if code := f.status(newClient(), "GET", "/api/v1/metadata", "", nil); code != http.StatusUnauthorized { + t.Fatal(code) + } +} diff --git a/internal/api/openapi.json b/internal/api/openapi.json index c894bb9..7058ccf 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -732,7 +732,7 @@ { "name": "note", "in": "query", - "required": true, + "required": false, "description": "所属笔记路径", "schema": { "type": "string" @@ -792,7 +792,8 @@ } } } - } + }, + "description": "note 与 folder 必须且只能指定一个;note 上传到笔记附件目录,folder 上传到目录的 assets 子目录。" } }, "/assets/import": { diff --git a/internal/vault/images.go b/internal/vault/images.go index aa1dacd..1c30f16 100644 --- a/internal/vault/images.go +++ b/internal/vault/images.go @@ -95,7 +95,18 @@ func movedPath(p, from, to string, directory bool) string { func rewriteMovedLinks(content []byte, oldNote, newNote, from, to string, directory, ownAssets bool) ([]byte, error) { var out []byte last := 0 + excluded := nonProseRanges(parseMetadata(content)) for _, span := range importLinkSpans(content) { + skip := false + for _, region := range excluded { + if span.start >= region[0] && span.start < region[1] { + skip = true + break + } + } + if skip { + continue + } if span.start < last { continue } diff --git a/internal/vault/links.go b/internal/vault/links.go index cd22aeb..d7af993 100644 --- a/internal/vault/links.go +++ b/internal/vault/links.go @@ -7,7 +7,7 @@ import ( ) // RewriteAssetLinks 在笔记改名后,把正文中指向旧图片目录(oldBase.assets/)的链接改为新目录。 -// 覆盖行内链接/图片 `](…)`、引用定义 `[x]: …` 与 HTML `src="…"`,跳过代码块和行内代码。 +// 覆盖行内链接/图片 `](…)`、引用定义 `[x]: …` 与 HTML `src="…"`,跳过代码与公式。 func RewriteAssetLinks(content []byte, oldBase, newBase string) []byte { oldRaw := regexp.QuoteMeta(oldBase + AssetsExt + "/") oldEsc := regexp.QuoteMeta(EscapeLinkPath(oldBase+AssetsExt) + "/") @@ -16,9 +16,30 @@ func RewriteAssetLinks(content []byte, oldBase, newBase string) []byte { refDef := regexp.MustCompile(`^( {0,3}\[[^\]]+\]:\s*= region[0] && offset+match[0] < region[1] { + skip = true + break + } + } + if skip { + continue + } + out = append(out, source[last:match[0]]...) + out = pattern.Expand(out, repl, source, match) + last = match[1] + } + return append(out, source[last:]...) + } + return transformMarkdownLines(content, refDef, func(line, _ []byte, offset int) []byte { + line = transformOutsideCode(line, func(text []byte, start int) []byte { return replace(inline, text, offset+start) }) + return replace(refDef, line, offset) }) } diff --git a/internal/vault/metadata.go b/internal/vault/metadata.go index d6609f3..ba2ea68 100644 --- a/internal/vault/metadata.go +++ b/internal/vault/metadata.go @@ -13,7 +13,6 @@ import ( "unicode/utf8" "github.com/yuin/goldmark/v2/ast" - "github.com/yuin/goldmark/v2/parser" "go.yaml.in/yaml/v3" ) @@ -111,7 +110,7 @@ func Analyze(note string, content []byte) Metadata { } } } - root := parser.New().Parse(source) + root := parseMetadata(source) _ = ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { return ast.WalkContinue, nil @@ -129,16 +128,16 @@ func Analyze(note string, content []byte) Metadata { } } switch node := n.(type) { - case *ast.CodeBlock, *ast.CodeSpan, *ast.HTMLBlock, *ast.RawHTML, *ast.LinkReferenceDefinition: + case *ast.AutoLink, *ast.CodeBlock, *ast.CodeSpan, *ast.HTMLBlock, *ast.RawHTML, *ast.LinkReferenceDefinition: return ast.WalkSkipChildren, nil case *ast.Link: - addRef(node.Destination.Value(source), node.Pos()) + addRef(node.Destination.Str(source), node.Pos()) return ast.WalkSkipChildren, nil case *ast.Image: - addRef(node.Destination.Value(source), node.Pos()) + addRef(node.Destination.Str(source), node.Pos()) return ast.WalkSkipChildren, nil case *ast.Text: - value := node.Value.Value(source) + value := node.Value.Str(source) for _, loc := range inlineTag.FindAllStringIndex(value, -1) { if loc[0] > 0 { prev, _ := utf8.DecodeLastRuneInString(value[:loc[0]]) @@ -152,10 +151,21 @@ func Analyze(note string, content []byte) Metadata { return ast.WalkContinue, nil }) // HTML 资源也可能被历史导入的笔记引用;清理不能漏掉它们。 + excluded := nonProseRanges(root) for _, span := range importLinkSpans(content) { if !span.html { continue } + skip := false + for _, region := range excluded { + if span.start >= region[0] && span.start < region[1] { + skip = true + break + } + } + if skip { + continue + } target, _, ok := LocalTarget(note, string(content[span.start:span.end])) if ok { m.Assets = append(m.Assets, Reference{target, bytes.Count(content[:span.start], []byte("\n")) + 1}) diff --git a/internal/vault/metadata_math.go b/internal/vault/metadata_math.go new file mode 100644 index 0000000..edf4d2b --- /dev/null +++ b/internal/vault/metadata_math.go @@ -0,0 +1,104 @@ +package vault + +import ( + "bytes" + "unicode" + "unicode/utf8" + + "github.com/yuin/goldmark/v2/ast" + "github.com/yuin/goldmark/v2/extension" + "github.com/yuin/goldmark/v2/parser" + "github.com/yuin/goldmark/v2/text" + "github.com/yuin/goldmark/v2/util" +) + +// 仅供关系提取:公式与代码同属非正文,不能把其中的 #1 或示例链接当成知识关系。 +// 分隔规则与前端 Typora 扩展一致;此解析器不参与 Markdown 渲染。 +type metadataMath struct{} + +func (metadataMath) Trigger() []byte { return []byte{'$'} } +func (metadataMath) Open(parent ast.Node, reader text.Reader, _ parser.Context) (ast.Node, parser.State) { + line, _ := reader.PeekLine() + if parent.Kind() != ast.KindDocument || !bytes.Equal(bytes.TrimSpace(line), []byte("$$")) { + return nil, parser.NoChildren + } + return ast.NewCodeBlock(ast.CodeBlockKindFenced, text.Lines{}), parser.NoChildren +} +func (metadataMath) Continue(node ast.Node, reader text.Reader, _ parser.Context) parser.State { + line, segment := reader.PeekLine() + reader.AdvanceToEOL() + if bytes.Equal(bytes.TrimSpace(line), []byte("$$")) { + return parser.Close + } + node.(*ast.CodeBlock).Value.AppendSegment(segment) + return parser.Continue | parser.NoChildren +} +func (metadataMath) Close(ast.Node, text.Reader, parser.Context) {} +func (metadataMath) CanInterruptParagraph() bool { return true } +func (metadataMath) CanAcceptIndentedLine() bool { return false } +func (metadataMath) Parse(_ ast.Node, reader text.Reader, _ parser.Context) ast.Node { + line, first := reader.PeekLine() + if len(line) < 2 || line[1] == '$' { + return nil + } + next, _ := utf8.DecodeRune(line[1:]) + if unicode.IsSpace(next) { + return nil + } + savedLine, savedPosition := reader.Position() + reader.Advance(1) + for { + line, segment := reader.PeekLine() + if line == nil { + reader.SetPosition(savedLine, savedPosition) + return nil + } + for i := 0; i < len(line); i++ { + if line[i] == '\\' { + i++ + continue + } + if line[i] != '$' || i == 0 { + continue + } + prev, _ := utf8.DecodeLastRune(line[:i]) + if unicode.IsSpace(prev) { + continue + } + if i+1 < len(line) && line[i+1] >= '0' && line[i+1] <= '9' { + continue + } + reader.Advance(i + 1) + return ast.NewCodeSpan(text.NewSingleLineValueFromIndex(text.NewIndex(first.Start, segment.Start+i+1), nil)) + } + reader.AdvanceLine() + } +} +func parseMetadata(source []byte) ast.Node { + math := metadataMath{} + return parser.New(parser.WithExtensions(extension.GFMParser, extension.FootnoteParser), parser.WithBlockParsers(util.Prioritized[parser.BlockParser](math, 650)), parser.WithInlineParsers(util.Prioritized[parser.InlineParser](math, 150))).Parse(source) +} + +// 返回非正文的字节范围,供无损链接改写器跳过公式中的示例语法。 +func nonProseRanges(root ast.Node) [][2]int { + var ranges [][2]int + _ = ast.Walk(root, func(node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + switch node := node.(type) { + case *ast.CodeSpan: + for _, index := range node.Value.Indices() { + ranges = append(ranges, [2]int{index.Start, index.Stop}) + } + return ast.WalkSkipChildren, nil + case *ast.CodeBlock: + for _, index := range node.Value.Segments() { + ranges = append(ranges, [2]int{index.Start, index.Stop}) + } + return ast.WalkSkipChildren, nil + } + return ast.WalkContinue, nil + }) + return ranges +} diff --git a/internal/vault/metadata_test.go b/internal/vault/metadata_test.go index 0281b88..b543a0b 100644 --- a/internal/vault/metadata_test.go +++ b/internal/vault/metadata_test.go @@ -73,3 +73,23 @@ func TestMoveMaintainsIncomingAndOutgoingLinks(t *testing.T) { t.Fatal("移动主体未回滚", err) } } + +func TestMetadataSkipsMathAndResolvesFootnotes(t *testing.T) { + content := "#正文\n\n公式 $x + #1 + [fake](bad.md)$\n\n$$\n#2 [fake](bad2.md) \n$$\n\n脚注[^a]\n\n[^a]: ![图片](assets/foot.png)\n\n\\#escaped https://example.com/?x=#url\n" + m := Analyze("a.md", []byte(content)) + if !reflect.DeepEqual(m.Tags, []string{"正文"}) || len(m.Links) != 0 || len(m.Assets) != 1 || m.Assets[0].Target != "assets/foot.png" { + t.Fatalf("扩展语法误识别:%+v", m) + } + rewritten, err := rewriteMovedLinks([]byte(content), "a.md", "dir/a.md", "a.md", "dir/a.md", false, false) + if err != nil || !strings.Contains(string(rewritten), "[fake](bad.md)") || !strings.Contains(string(rewritten), "[fake](bad2.md)") || !strings.Contains(string(rewritten), "../assets/foot.png") { + t.Fatalf("公式或脚注改写不符:%s %v", rewritten, err) + } +} + +func TestRenamedAssetsPreserveMathExamples(t *testing.T) { + content := "公式 $x + ![](a.assets/fake.png)$\n\n$$\n\n$$\n\n![](a.assets/real.png)\n" + got := string(RewriteAssetLinks([]byte(content), "a", "b")) + if !strings.Contains(got, "$x + ![](a.assets/fake.png)$") || !strings.Contains(got, "src=\"a.assets/fake.png\"") || !strings.Contains(got, "![](b.assets/real.png)") { + t.Fatalf("公式示例被修改:%s", got) + } +} diff --git a/web/src/app/AppHeader.vue b/web/src/app/AppHeader.vue index 16dc12b..537e622 100644 --- a/web/src/app/AppHeader.vue +++ b/web/src/app/AppHeader.vue @@ -7,7 +7,7 @@ import { createNote, ws } from './workspace'
个人知识库 - +
diff --git a/web/src/app/AppearanceSettings.vue b/web/src/app/AppearanceSettings.vue index ebadbf8..682c982 100644 --- a/web/src/app/AppearanceSettings.vue +++ b/web/src/app/AppearanceSettings.vue @@ -75,6 +75,7 @@ async function reset() { diff --git a/web/src/app/EditorPane.vue b/web/src/app/EditorPane.vue index b548114..948d35f 100644 --- a/web/src/app/EditorPane.vue +++ b/web/src/app/EditorPane.vue @@ -48,14 +48,17 @@ function show() { onMounted(() => { view = createEditorView(host.value!, EditorState.create()) attachView(view) + document.fonts.addEventListener('loadingdone', measure) show() }) +function measure() { view.requestMeasure() } onBeforeUnmount(() => { + document.fonts.removeEventListener('loadingdone', measure) if (shown) storeViewState(shown.path, view.state) attachView(null) view.destroy() }) -watch(() => [ws.active, tab.value?.status], show, { flush: 'post' }) +watch(() => [ws.active, tab.value?.status, ws.view, ws.images], () => { show(); requestAnimationFrame(() => view.requestMeasure()) }, { flush: 'post' })