Add image listing and asset management

Show images in asset directories with preview, rename, replacement, and trash recovery. Update local Markdown references when images move and restore original files if related metadata cannot be committed.

Refresh image previews after replacement and compare actual contents when deduplicating subsequent uploads.
This commit is contained in:
eezd committed 2026-10-21 15:14:07 +08:00
1 parent 8b54ccba18
commit d253536009
25 files changed
+795 -97

No files matched your search

+1
View File
@@ -31,6 +31,7 @@ docker run -d --name rawmark \
- 首次打开直接设置登录密码,之后凭密码登录;请在将新实例开放给他人访问前完成初始化。 - 首次打开直接设置登录密码,之后凭密码登录;请在将新实例开放给他人访问前完成初始化。
- 笔记以普通 `.md` 文件保存在 `数据目录/vault/`,图片按 Typora 习惯存放在同级的 `笔记名.assets/`,可直接用 Typora 等编辑器打开;外部修改会实时同步到网页。 - 笔记以普通 `.md` 文件保存在 `数据目录/vault/`,图片按 Typora 习惯存放在同级的 `笔记名.assets/`,可直接用 Typora 等编辑器打开;外部修改会实时同步到网页。
- 图片管理:展开侧栏的 `assets` 或 `笔记名.assets`,点击图片预览,可重命名、替换或移入回收站。图片改名/移动会更新笔记中的本地引用;替换保留路径,选择相同扩展名的图片(最大 50 MB),旧图片不进回收站。
- `--user` 让容器以宿主机用户身份读写数据目录;不指定时容器以 UID 65532 运行,需要该用户对目录有写权限。 - `--user` 让容器以宿主机用户身份读写数据目录;不指定时容器以 UID 65532 运行,需要该用户对目录有写权限。
- 请通过 HTTPS 反向代理(Caddy、Nginx 等)访问,并转发 WebSocket(`/api/v1/events`)。 - 请通过 HTTPS 反向代理(Caddy、Nginx 等)访问,并转发 WebSocket(`/api/v1/events`)。
- 分享:命令面板(F1)中「分享当前笔记」生成只读链接 `/s/…`,可设访问密码与有效期;「管理分享链接」查看与撤销。 - 分享:命令面板(F1)中「分享当前笔记」生成只读链接 `/s/…`,可设访问密码与有效期;「管理分享链接」查看与撤销。
+30 -9
View File
@@ -4,7 +4,6 @@ package api
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"io" "io"
"io/fs" "io/fs"
"log/slog" "log/slog"
@@ -85,6 +84,7 @@ func (s *Server) routes() []route {
{"POST /api/v1/import", authed, s.importNotes}, {"POST /api/v1/import", authed, s.importNotes},
{"POST /api/v1/assets", authed, s.uploadAsset}, {"POST /api/v1/assets", authed, s.uploadAsset},
{"POST /api/v1/assets/import", authed, s.importAsset}, {"POST /api/v1/assets/import", authed, s.importAsset},
{"PUT /api/v1/assets/{path...}", authed, s.replaceImage},
{"GET /api/v1/raw/{path...}", authed, s.raw}, {"GET /api/v1/raw/{path...}", authed, s.raw},
{"GET /api/v1/trash", authed, s.trashList}, {"GET /api/v1/trash", authed, s.trashList},
{"POST /api/v1/trash/{id}/restore", authed, s.trashRestore}, {"POST /api/v1/trash/{id}/restore", authed, s.trashRestore},
@@ -352,18 +352,13 @@ func (s *Server) move(w http.ResponseWriter, r *http.Request) {
if !readJSON(w, r, &body) { if !readJSON(w, r, &body) {
return return
} }
rewritten, err := s.vault.Move(body.From, body.To) rewritten, err := s.vault.Move(body.From, body.To, func() error {
return s.store.MoveShares(body.From, body.To)
})
if err != nil { if err != nil {
vaultError(w, r, err) vaultError(w, r, err)
return return
} }
if err := s.store.MoveShares(body.From, body.To); err != nil {
if _, rollback := s.vault.Move(body.To, body.From); rollback != nil {
err = errors.Join(err, fmt.Errorf("回滚笔记移动失败: %w", rollback))
}
internalError(w, r, err)
return
}
if rewritten { if rewritten {
// 原子改写替换了文件身份,监听器无法再从快照中识别这次移动。 // 原子改写替换了文件身份,监听器无法再从快照中识别这次移动。
s.hub.Broadcast(events.Event{Type: "moved", From: body.From, Path: body.To}, events.Event{Type: "tree"}) s.hub.Broadcast(events.Event{Type: "moved", From: body.From, Path: body.To}, events.Event{Type: "tree"})
@@ -400,6 +395,32 @@ func (s *Server) uploadAsset(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]string{"link": link}) writeJSON(w, 200, map[string]string{"link": link})
} }
// replaceImage 仅替换已存在的图片;路径与引用保持不变。
func (s *Server) replaceImage(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxAssetBytes+1<<20)
if err := r.ParseMultipartForm(1 << 20); err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
writeError(w, 413, "图片不能超过 50 MB")
} else {
writeError(w, 400, "无法读取上传文件")
}
return
}
defer r.MultipartForm.RemoveAll()
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, 400, "缺少上传文件")
return
}
defer file.Close()
if err := s.vault.ReplaceImage(r.PathValue("path"), header.Filename, file, maxAssetBytes); err != nil {
vaultError(w, r, err)
return
}
writeJSON(w, 200, map[string]bool{"ok": true})
}
// raw 输出仓库内任意非隐藏文件(主要是图片)。以沙箱 CSP 返回,防止 SVG/HTML 中的脚本在本站执行 // raw 输出仓库内任意非隐藏文件(主要是图片)。以沙箱 CSP 返回,防止 SVG/HTML 中的脚本在本站执行
func (s *Server) raw(w http.ResponseWriter, r *http.Request) { func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
s.serveRaw(w, r, r.PathValue("path")) s.serveRaw(w, r, r.PathValue("path"))
+58
View File
@@ -0,0 +1,58 @@
package api
import (
"bytes"
"io"
"mime/multipart"
"net/http"
"testing"
)
func TestReplaceImageAPI(t *testing.T) {
f := newFixture(t)
f.write("assets/a.png", "original")
f.write("a.md", "![](assets/a.png)")
var body bytes.Buffer
form := multipart.NewWriter(&body)
file, err := form.CreateFormFile("file", "replacement.png")
if err != nil {
t.Fatal(err)
}
if _, err := io.WriteString(file, "replacement"); err != nil {
t.Fatal(err)
}
if err := form.Close(); err != nil {
t.Fatal(err)
}
headers := map[string]string{"Content-Type": form.FormDataContentType()}
if code := f.status(newClient(), "PUT", "/api/v1/assets/assets/a.png", body.String(), headers); code != 401 {
t.Fatalf("未登录应被拒绝:%d", code)
}
readToken := decode[struct{ Token string }](t, f.do(f.admin, "POST", "/api/v1/tokens", `{"name":"read","scope":"read"}`, nil)).Token
if code := f.status(newClient(), "PUT", "/api/v1/assets/assets/a.png", body.String(), map[string]string{"Content-Type": form.FormDataContentType(), "Authorization": "Bearer " + readToken}); code != 403 {
t.Fatalf("只读令牌不能替换图片:%d", code)
}
for route, want := range map[string]int{"/api/v1/assets/assets/a.png": 200, "/api/v1/assets/missing.png": 404, "/api/v1/assets/a.md": 400} {
if code := f.status(f.admin, "PUT", route, body.String(), headers); code != want {
t.Fatalf("%s:得到 %d,期望 %d", route, code, want)
}
}
res := f.do(f.admin, "GET", "/api/v1/raw/assets/a.png", "", nil)
data, err := io.ReadAll(res.Body)
if err != nil || string(data) != "replacement" || res.Header.Get("Content-Security-Policy") == "" || res.Header.Get("Cache-Control") != "no-store" {
t.Fatalf("替换内容或安全边界错误:%q %v", data, err)
}
note := f.do(f.admin, "GET", "/api/v1/files/a.md", "", nil)
data, err = io.ReadAll(note.Body)
if err != nil || string(data) != "![](assets/a.png)" {
t.Fatalf("替换不应改变引用:%q %v", data, err)
}
// 复用现有移动与回收站接口,图片不会被当作 Markdown 正文读取。
if code := f.status(f.admin, "POST", "/api/v1/move", `{"from":"assets/a.png","to":"assets/b.png"}`, nil); code != 200 {
t.Fatal(code)
}
deleted := decode[struct{ ID string }](t, f.do(f.admin, "DELETE", "/api/v1/files/assets/b.png", "", nil))
if code := f.status(f.admin, "POST", "/api/v1/trash/"+deleted.ID+"/restore", "", nil); code != http.StatusOK {
t.Fatal(code)
}
}
+57 -4
View File
@@ -398,7 +398,7 @@
}, },
"/tree": { "/tree": {
"get": { "get": {
"summary": "全部笔记与文件夹(不含 .assets 图片目录与隐藏文件)", "summary": "全部笔记、图片与文件夹(含 .assets,不含隐藏条目或符号链接)",
"tags": [ "tags": [
"笔记" "笔记"
], ],
@@ -583,7 +583,7 @@
} }
}, },
"delete": { "delete": {
"summary": "把笔记或文件夹移入回收站(连同 .assets)", "summary": "把笔记、图片或文件夹移入回收站(笔记连同 .assets)",
"tags": [ "tags": [
"笔记" "笔记"
], ],
@@ -613,11 +613,11 @@
}, },
"/move": { "/move": {
"post": { "post": {
"summary": "移动或重命名笔记/文件夹", "summary": "移动或重命名笔记、图片或文件夹",
"tags": [ "tags": [
"笔记" "笔记"
], ],
"description": "笔记的 .assets 目录随之移动,改名时同步改写正文中指向自身图片目录的链接;分享链接随之更新。不改写其他笔记指向它的链接。", "description": "笔记的 .assets 目录随之移动,改名时改写本篇图片目录链接;图片移动或改名保持扩展名,并更新仓库笔记中的本地引用。分享链接随主体更新。不改写其他笔记指向笔记或文件夹的链接。",
"requestBody": { "requestBody": {
"required": true, "required": true,
"content": { "content": {
@@ -2273,6 +2273,59 @@
} }
} }
} }
},
"/assets/{path}": {
"put": {
"summary": "原位替换已有图片,保留路径和引用",
"tags": [
"图片"
],
"parameters": [
{
"name": "path",
"in": "path",
"required": true,
"description": "仓库中的图片路径",
"schema": {
"type": "string"
}
}
],
"description": "multipart file 字段的扩展名须与目标图片一致;不创建新文件,最大 50 MB。旧图片不会进入回收站。",
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"file": {
"type": "string",
"format": "binary"
}
},
"required": [
"file"
]
}
}
}
},
"responses": {
"200": {
"description": "替换成功"
},
"400": {
"description": "非法路径、空文件或扩展名不一致"
},
"404": {
"description": "目标图片不存在"
},
"413": {
"description": "超过 50 MB"
}
}
}
} }
}, },
"components": { "components": {
+125
View File
@@ -0,0 +1,125 @@
package vault
import (
"bytes"
"errors"
"fmt"
"html"
"io"
"io/fs"
"net/url"
"os"
"path"
"path/filepath"
"sort"
"strings"
)
// ReplaceImage 原位替换已有图片,保留所有引用路径;不允许借此创建文件或改变格式。
func (v *Vault) ReplaceImage(rel, filename string, r io.Reader, limit int64) error {
if !IsImage(rel) || !strings.EqualFold(path.Ext(rel), path.Ext(filename)) {
return fmt.Errorf("%w:请选择与原图片扩展名相同的文件", ErrInvalidPath)
}
p, err := v.abs(rel)
if err != nil {
return err
}
data, err := io.ReadAll(io.LimitReader(r, limit+1))
if err != nil {
return err
}
if int64(len(data)) > limit {
return ErrTooLarge
}
if len(data) == 0 {
return fmt.Errorf("%w:图片不能为空", ErrInvalidPath)
}
v.mu.Lock()
defer v.mu.Unlock()
info, err := os.Lstat(p)
if errors.Is(err, fs.ErrNotExist) {
return ErrNotFound
}
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return ErrInvalidPath
}
return atomicWrite(p, data)
}
type imageLinkChange struct {
path string
before, after []byte
}
// 调用方持有写锁;所有原始内容留到移动成功,以便复合操作失败时回滚。
func (v *Vault) imageLinkChanges(from, to string) ([]imageLinkChange, error) {
entries, err := v.Tree()
if err != nil {
return nil, err
}
var changes []imageLinkChange
for _, e := range entries {
if e.Dir || !IsNote(e.Path) {
continue
}
p := filepath.Join(v.root, filepath.FromSlash(e.Path))
before, err := os.ReadFile(p)
if err != nil {
return nil, err
}
after, err := rewriteImageLinks(before, e.Path, from, to)
if err != nil {
return nil, err
}
if !bytes.Equal(before, after) {
changes = append(changes, imageLinkChange{p, before, after})
}
}
return changes, nil
}
func rewriteImageLinks(content []byte, note, from, to string) ([]byte, error) {
rel, err := filepath.Rel(filepath.FromSlash(path.Dir(note)), filepath.FromSlash(to))
if err != nil {
return nil, err
}
transform := func(text []byte) []byte {
spans := importLinkSpans(text)
sort.Slice(spans, func(i, j int) bool { return spans[i].start < spans[j].start })
var out []byte
last := 0
for _, span := range spans {
if span.start < last {
continue
}
target := string(text[span.start:span.end])
if span.html {
target = html.UnescapeString(target)
} else {
target = markdownEscape.ReplaceAllString(target, "$1")
}
u, err := url.Parse(target)
if err != nil || u.IsAbs() || u.Host != "" || strings.HasPrefix(target, "/") || u.Path == "" || path.Clean(path.Join(path.Dir(note), u.Path)) != from {
continue
}
replacement := EscapeLinkPath(filepath.ToSlash(rel))
if u.RawQuery != "" {
replacement += "?" + u.RawQuery
}
if u.Fragment != "" {
replacement += "#" + u.EscapedFragment()
}
if span.html {
replacement = html.EscapeString(replacement)
}
out = append(out, text[last:span.start]...)
out = append(out, replacement...)
last = span.end
}
return append(out, text[last:]...)
}
return transformMarkdownLines(content, importRef, func(line []byte) []byte { return transformOutsideCode(line, transform) }), nil
}
+156
View File
@@ -0,0 +1,156 @@
package vault
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestImageMoveUpdatesLocalReferences(t *testing.T) {
v := newVault(t)
if err := os.MkdirAll(filepath.Join(v.Root(), "assets"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(v.Root(), "assets/原图.png"), []byte("image"), 0o644); err != nil {
t.Fatal(err)
}
content := "![图](../assets/原图.png?q=1#part \"标题\")\n[ref]: <../assets/原图.png>\n<img src=\"../assets/原图.png?q=1&amp;x=2\">\n`![](../assets/原图.png)`\n\n```md\n![](../assets/原图.png)\n```\n![](https://example.com/assets/原图.png)\n![](../assets/其他.png)\n"
if _, err := v.Write("notes/a.md", []byte(content), Precondition{}); err != nil {
t.Fatal(err)
}
if _, err := v.Move("assets/原图.png", "elsewhere/新图 #.png", nil); err != nil {
t.Fatal(err)
}
out, _, err := v.Read("notes/a.md")
if err != nil {
t.Fatal(err)
}
want := strings.ReplaceAll(content, "../assets/原图.png", "../elsewhere/新图%20%23.png")
want = strings.ReplaceAll(want, "`![](../elsewhere/新图%20%23.png)`", "`![](../assets/原图.png)`")
want = strings.ReplaceAll(want, "```md\n![](../elsewhere/新图%20%23.png)", "```md\n![](../assets/原图.png)")
if string(out) != want {
t.Fatalf("链接改写不符:\n%s\n期望:\n%s", out, want)
}
if _, err := v.Move("elsewhere/新图 #.png", "elsewhere/新图.jpg", nil); !errors.Is(err, ErrInvalidPath) {
t.Fatalf("不能用重命名改变图片格式:%v", err)
}
item, err := v.Delete("elsewhere/新图 #.png")
if err != nil {
t.Fatal(err)
}
if item.Assets != "" {
t.Fatal("图片不应带走同名笔记附件目录")
}
if _, err := v.Restore(item.ID); err != nil {
t.Fatal(err)
}
f, _, err := v.OpenRaw("elsewhere/新图 #.png")
if err != nil {
t.Fatal(err)
}
f.Close()
}
func TestReplaceImagePreservesPathAndUploadContents(t *testing.T) {
v := newVault(t)
link, err := v.SaveAsset("a.md", "old.png", strings.NewReader("original"), 100)
if err != nil {
t.Fatal(err)
}
if err := v.ReplaceImage(link, "replacement.PNG", strings.NewReader("replacement"), 100); err != nil {
t.Fatal(err)
}
newLink, err := v.SaveAsset("a.md", "old.png", strings.NewReader("original"), 100)
if err != nil {
t.Fatal(err)
}
again, err := v.SaveAsset("a.md", "old.png", strings.NewReader("original"), 100)
if err != nil || again != newLink || newLink == link {
t.Fatalf("替换后的去重不符:%q %q %q %v", link, newLink, again, err)
}
for name, want := range map[string]string{link: "replacement", newLink: "original"} {
data, err := os.ReadFile(filepath.Join(v.Root(), name))
if err != nil || string(data) != want {
t.Fatalf("图片内容错误:%q %q %v", name, data, err)
}
}
for _, tc := range []struct {
path, name, data string
limit int64
want error
}{
{link, "x.jpg", "new", 100, ErrInvalidPath},
{link, "x.png", "", 100, ErrInvalidPath},
{link, "x.png", "too big", 1, ErrTooLarge},
{"missing.png", "x.png", "new", 100, ErrNotFound},
{"../escape.png", "x.png", "new", 100, ErrInvalidPath},
{"a.md", "x.md", "new", 100, ErrInvalidPath},
} {
if err := v.ReplaceImage(tc.path, tc.name, strings.NewReader(tc.data), tc.limit); !errors.Is(err, tc.want) {
t.Errorf("替换 %s:得到 %v,期望 %v", tc.path, err, tc.want)
}
}
data, err := os.ReadFile(filepath.Join(v.Root(), link))
if err != nil || string(data) != "replacement" {
t.Fatalf("无效替换不能影响原图:%q %v", data, err)
}
}
func TestImagesDoNotExposeOrModifySymlinks(t *testing.T) {
v := newVault(t)
outside := filepath.Join(t.TempDir(), "private.png")
if err := os.WriteFile(outside, []byte("private"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(v.Root(), "link.png")); err != nil {
t.Fatal(err)
}
entries, err := v.Tree()
if err != nil || len(entries) != 0 {
t.Fatalf("图片列表不应公开符号链接:%v %v", entries, err)
}
if err := v.ReplaceImage("link.png", "x.png", strings.NewReader("new"), 100); !errors.Is(err, ErrInvalidPath) {
t.Fatalf("不能替换仓库外文件:%v", err)
}
}
func TestMoveCommitFailureRestoresExactReferences(t *testing.T) {
for _, source := range []string{"from.png", "from.md"} {
t.Run(source, func(t *testing.T) {
v := newVault(t)
content := "![](from.assets/a.png)"
if _, err := v.Write("from.md", []byte(content), Precondition{}); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(v.Root(), "from.assets"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(v.Root(), "from.png"), []byte("image"), 0o644); err != nil {
t.Fatal(err)
}
refs := "![](from.png)\n![](to.png)"
if _, err := v.Write("refs.md", []byte(refs), Precondition{}); err != nil {
t.Fatal(err)
}
failure := errors.New("关联数据提交失败")
target := strings.Replace(source, "from", "to", 1)
if _, err := v.Move(source, target, func() error { return failure }); !errors.Is(err, failure) {
t.Fatal(err)
}
for name, want := range map[string]string{"from.md": content, "refs.md": refs} {
got, _, err := v.Read(name)
if err != nil || string(got) != want {
t.Fatalf("回滚须保留原始正文:%s %q %v", name, got, err)
}
}
if _, err := os.Stat(filepath.Join(v.Root(), target)); !os.IsNotExist(err) {
t.Fatalf("失败后目标仍存在:%v", err)
}
if _, err := os.Stat(filepath.Join(v.Root(), "from.assets")); err != nil {
t.Fatal(err)
}
})
}
}
+1 -3
View File
@@ -146,9 +146,7 @@ func (v *Vault) importLinks(ctx context.Context, note string, content []byte, to
if !info.Mode().IsRegular() { if !info.Mode().IsRegular() {
continue continue
} }
switch strings.ToLower(path.Ext(rel)) { if !IsImage(rel) {
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".avif", ".apng", ".bmp", ".ico", ".tif", ".tiff":
default:
continue continue
} }
replacement, found := replacements[rel] replacement, found := replacements[rel]
+1 -1
View File
@@ -70,7 +70,7 @@ func TestImportZipAssetsAndMove(t *testing.T) {
if err := v.Mkdir("移动目标"); err != nil { if err := v.Mkdir("移动目标"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := v.Move("知识库/目录/笔记.md", "移动目标/改名.md"); err != nil { if _, err := v.Move("知识库/目录/笔记.md", "移动目标/改名.md", nil); err != nil {
t.Fatal(err) t.Fatal(err)
} }
moved, _, err := v.Read("移动目标/改名.md") moved, _, err := v.Read("移动目标/改名.md")
+81 -46
View File
@@ -144,11 +144,6 @@ func (v *Vault) checkInside(p string) error {
} }
} }
func (v *Vault) rel(abs string) string {
r, _ := filepath.Rel(v.root, abs)
return filepath.ToSlash(r)
}
// ---- 读写 ---- // ---- 读写 ----
func (v *Vault) Read(rel string) ([]byte, string, error) { func (v *Vault) Read(rel string) ([]byte, string, error) {
@@ -295,42 +290,32 @@ func (v *Vault) OpenRaw(rel string) (*os.File, fs.FileInfo, error) {
// ---- 列表 ---- // ---- 列表 ----
// skipName 判断目录树中不展示的条目:隐藏文件/目录(含临时文件)与 .assets 图片目录 // IsImage 判断当前支持管理的图片扩展名,不用于验证文件内容。
func skipName(name string, dir bool) bool { func IsImage(p string) bool {
return strings.HasPrefix(name, ".") || (dir && strings.HasSuffix(name, AssetsExt)) switch strings.ToLower(path.Ext(p)) {
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".avif", ".apng", ".bmp", ".ico", ".tif", ".tiff":
return true
default:
return false
}
} }
// Tree 列出所有文件夹与 .md 笔记 // Tree 列出文件夹、笔记和图片,沿用 TreeAt 的隐藏路径与符号链接边界。
func (v *Vault) Tree() ([]Entry, error) { func (v *Vault) Tree() ([]Entry, error) {
entries := []Entry{} entries, err := v.TreeAt("")
err := filepath.WalkDir(v.root, func(p string, d fs.DirEntry, err error) error {
if err != nil { if err != nil {
return err return nil, err
} }
if p == v.root { visible := []Entry{}
return nil for _, e := range entries {
if e.Dir {
e.Size = 0
} }
if skipName(d.Name(), d.IsDir()) { if e.Dir || IsImage(e.Path) || (IsNote(e.Path) && !strings.Contains(e.Path, AssetsExt+"/")) {
if d.IsDir() { visible = append(visible, e)
return filepath.SkipDir
} }
return nil
} }
if !d.IsDir() && !IsNote(d.Name()) { return visible, nil
return nil
}
info, err := d.Info()
if err != nil {
return err
}
e := Entry{Path: v.rel(p), Dir: d.IsDir(), ModTime: info.ModTime().UnixMilli()}
if !e.Dir {
e.Size = info.Size()
}
entries = append(entries, e)
return nil
})
return entries, err
} }
// TreeAt 递归列出指定目录中的普通文件和子目录,路径相对此目录;空路径表示仓库根,不公开隐藏条目或符号链接。 // TreeAt 递归列出指定目录中的普通文件和子目录,路径相对此目录;空路径表示仓库根,不公开隐藏条目或符号链接。
@@ -413,9 +398,10 @@ func (v *Vault) ScopedPath(root, rel string) (string, error) {
// ---- 移动 / 重命名 ---- // ---- 移动 / 重命名 ----
// Move 移动或重命名笔记/文件夹,返回是否原子改写了正文(改变文件身份)。 // Move 移动或重命名笔记、图片或文件夹,返回是否原子改写了移动主体(改变文件身份)。
// 笔记的 .assets 目录随之移动;笔记改名时同步改写正文中指向自身图片目录的链接。 // 笔记的 .assets 目录随之移动;笔记改名时同步改写正文中指向自身图片目录的链接。
func (v *Vault) Move(from, to string) (rewritten bool, err error) { // commit 用于提交关联元数据;失败时在释放写锁前恢复文件及原始正文,nil 表示无关联元数据。
func (v *Vault) Move(from, to string, commit func() error) (rewritten bool, err error) {
src, err := v.abs(from) src, err := v.abs(from)
if err != nil { if err != nil {
return false, err return false, err
@@ -436,8 +422,10 @@ func (v *Vault) Move(from, to string) (rewritten bool, err error) {
if err != nil { if err != nil {
return false, err return false, err
} }
isNote := !info.IsDir() isNote := !info.IsDir() && IsNote(from)
if isNote && (!IsNote(from) || !IsNote(to)) { isImage := !info.IsDir() && IsImage(from)
validTarget := (isNote && IsNote(to)) || (isImage && strings.EqualFold(path.Ext(from), path.Ext(to)))
if !info.IsDir() && (!info.Mode().IsRegular() || !validTarget) {
return false, ErrInvalidPath return false, ErrInvalidPath
} }
if _, err := os.Lstat(dst); err == nil { if _, err := os.Lstat(dst); err == nil {
@@ -452,6 +440,14 @@ func (v *Vault) Move(from, to string) (rewritten bool, err error) {
} }
} }
} }
// 图片改名影响引用它的笔记:先读取全部待改正文,失败时不能先移动图片。
var links []imageLinkChange
if isImage {
links, err = v.imageLinkChanges(from, to)
if err != nil {
return false, err
}
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return false, err return false, err
} }
@@ -459,10 +455,22 @@ func (v *Vault) Move(from, to string) (rewritten bool, err error) {
return false, err return false, err
} }
assetsMoved := false assetsMoved := false
writtenLinks := 0
var originalNote []byte
defer func() { defer func() {
if err == nil { if err == nil {
return return
} }
if rewritten {
if rollback := atomicWrite(dst, originalNote); rollback != nil {
err = errors.Join(err, fmt.Errorf("回滚笔记正文失败: %w", rollback))
}
}
for _, change := range links[:writtenLinks] {
if rollback := atomicWrite(change.path, change.before); rollback != nil {
err = errors.Join(err, fmt.Errorf("回滚图片引用失败: %w", rollback))
}
}
if assetsMoved { if assetsMoved {
if rollback := os.Rename(dstAssets, srcAssets); rollback != nil { if rollback := os.Rename(dstAssets, srcAssets); rollback != nil {
err = errors.Join(err, fmt.Errorf("回滚图片移动失败: %w", rollback)) err = errors.Join(err, fmt.Errorf("回滚图片移动失败: %w", rollback))
@@ -472,6 +480,12 @@ func (v *Vault) Move(from, to string) (rewritten bool, err error) {
err = errors.Join(err, fmt.Errorf("回滚笔记移动失败: %w", rollback)) err = errors.Join(err, fmt.Errorf("回滚笔记移动失败: %w", rollback))
} }
}() }()
for _, change := range links {
if err = atomicWrite(change.path, change.after); err != nil {
return false, err
}
writtenLinks++
}
if srcAssets != "" { if srcAssets != "" {
if err := os.Rename(srcAssets, dstAssets); err != nil { if err := os.Rename(srcAssets, dstAssets); err != nil {
return false, fmt.Errorf("移动图片目录失败: %w", err) return false, fmt.Errorf("移动图片目录失败: %w", err)
@@ -485,11 +499,18 @@ func (v *Vault) Move(from, to string) (rewritten bool, err error) {
return false, err return false, err
} }
if out := RewriteAssetLinks(data, oldBase, newBase); !bytes.Equal(out, data) { if out := RewriteAssetLinks(data, oldBase, newBase); !bytes.Equal(out, data) {
return true, atomicWrite(dst, out) if err = atomicWrite(dst, out); err != nil {
return false, err
}
originalNote = data
rewritten = true
} }
} }
} }
return false, nil if commit != nil {
err = commit()
}
return rewritten, err
} }
// ---- 回收站 ---- // ---- 回收站 ----
@@ -502,7 +523,7 @@ func newTrashID() string {
var trashIDPattern = regexp.MustCompile(`^\d{8}T\d{6}\.\d{3}-[0-9a-f]{8}$`) var trashIDPattern = regexp.MustCompile(`^\d{8}T\d{6}\.\d{3}-[0-9a-f]{8}$`)
// Delete 把笔记(连同 .assets)或文件夹移入回收站 // Delete 把笔记(连同 .assets)、图片或文件夹移入回收站
func (v *Vault) Delete(rel string) (item TrashItem, err error) { func (v *Vault) Delete(rel string) (item TrashItem, err error) {
p, err := v.abs(rel) p, err := v.abs(rel)
if err != nil { if err != nil {
@@ -517,11 +538,11 @@ func (v *Vault) Delete(rel string) (item TrashItem, err error) {
if err != nil { if err != nil {
return TrashItem{}, err return TrashItem{}, err
} }
if !info.IsDir() && !IsNote(rel) { if !info.IsDir() && (!info.Mode().IsRegular() || (!IsNote(rel) && !IsImage(rel))) {
return TrashItem{}, ErrInvalidPath return TrashItem{}, ErrInvalidPath
} }
item = TrashItem{ID: newTrashID(), Path: rel, Dir: info.IsDir(), DeletedAt: time.Now().UnixMilli()} item = TrashItem{ID: newTrashID(), Path: rel, Dir: info.IsDir(), DeletedAt: time.Now().UnixMilli()}
if !item.Dir { if !item.Dir && IsNote(rel) {
st, statErr := os.Stat(AssetsDir(p)) st, statErr := os.Stat(AssetsDir(p))
if statErr != nil && !errors.Is(statErr, fs.ErrNotExist) { if statErr != nil && !errors.Is(statErr, fs.ErrNotExist) {
return TrashItem{}, statErr return TrashItem{}, statErr
@@ -700,21 +721,35 @@ func (v *Vault) SaveAsset(note, origName string, r io.Reader, limit int64) (stri
dir := AssetsDir(p) dir := AssetsDir(p)
v.mu.Lock() v.mu.Lock()
defer v.mu.Unlock() defer v.mu.Unlock()
if err := v.checkInside(filepath.Join(dir, name)); err != nil { if err := v.checkInside(dir); err != nil {
return "", err return "", err
} }
if err := os.MkdirAll(dir, 0o755); err != nil { if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err return "", err
} }
target := filepath.Join(dir, name) // 管理界面允许替换图片,哈希名称不再保证内容未变;不能把旧内容上传误判为重复。
if _, err := os.Stat(target); errors.Is(err, fs.ErrNotExist) { base := strings.TrimSuffix(name, path.Ext(name))
for i := 0; ; i++ {
candidate := name
if i > 0 {
candidate = fmt.Sprintf("%s-%d%s", base, i, path.Ext(name))
}
target := filepath.Join(dir, candidate)
if err := v.checkInside(target); err != nil {
return "", err
}
existing, err := os.ReadFile(target)
if errors.Is(err, fs.ErrNotExist) {
if err := atomicWrite(target, data); err != nil { if err := atomicWrite(target, data); err != nil {
return "", err return "", err
} }
} else if err != nil { } else if err != nil {
return "", err return "", err
} else if !bytes.Equal(existing, data) {
continue
}
return EscapeLinkPath(path.Base(AssetsDir(note))) + "/" + candidate, nil
} }
return EscapeLinkPath(path.Base(AssetsDir(note))) + "/" + name, nil
} }
func assetName(data []byte, origName string) string { func assetName(data []byte, origName string) string {
+9 -6
View File
@@ -70,7 +70,7 @@ func TestMoveRollsBackWhenAssetDestinationIsTooLong(t *testing.T) {
} }
// 笔记名可写入,但派生的 .assets 名超过文件系统的单段长度限制。 // 笔记名可写入,但派生的 .assets 名超过文件系统的单段长度限制。
destination := strings.Repeat("a", 250) + ".md" destination := strings.Repeat("a", 250) + ".md"
if _, err := v.Move("n.md", destination); err == nil { if _, err := v.Move("n.md", destination, nil); err == nil {
t.Fatal("图片目录移动应失败") t.Fatal("图片目录移动应失败")
} }
if content, _, err := v.Read("n.md"); err != nil || string(content) != "原文" { if content, _, err := v.Read("n.md"); err != nil || string(content) != "原文" {
@@ -170,7 +170,7 @@ func TestMoveCarriesAssetsAndRewritesLinks(t *testing.T) {
if !strings.HasPrefix(link, "笔记%20A.assets/") { if !strings.HasPrefix(link, "笔记%20A.assets/") {
t.Fatalf("资源链接不符: %s", link) t.Fatalf("资源链接不符: %s", link)
} }
if rewritten, err := v.Move("dir/笔记 A.md", "other/B.md"); err != nil || !rewritten { if rewritten, err := v.Move("dir/笔记 A.md", "other/B.md", nil); err != nil || !rewritten {
t.Fatalf("应报告正文改写: %v %v", rewritten, err) t.Fatalf("应报告正文改写: %v %v", rewritten, err)
} }
data, _, err := v.Read("other/B.md") data, _, err := v.Read("other/B.md")
@@ -184,7 +184,7 @@ func TestMoveCarriesAssetsAndRewritesLinks(t *testing.T) {
if _, err := os.Stat(filepath.Join(v.Root(), "other", "B.assets", strings.TrimPrefix(link, "笔记%20A.assets/"))); err != nil { if _, err := os.Stat(filepath.Join(v.Root(), "other", "B.assets", strings.TrimPrefix(link, "笔记%20A.assets/"))); err != nil {
t.Fatal("图片目录未随笔记移动:", err) t.Fatal("图片目录未随笔记移动:", err)
} }
if _, err := v.Move("other", "other/sub"); !errors.Is(err, ErrInvalidPath) { if _, err := v.Move("other", "other/sub", nil); !errors.Is(err, ErrInvalidPath) {
t.Fatal("文件夹移入自身应被拒绝") t.Fatal("文件夹移入自身应被拒绝")
} }
} }
@@ -221,10 +221,13 @@ func TestDeleteRestore(t *testing.T) {
} }
} }
func TestTreeHidesMetaAndAssets(t *testing.T) { func TestTreeShowsImagesButHidesMeta(t *testing.T) {
v := newVault(t) v := newVault(t)
v.Write("a.md", []byte("x"), Precondition{}) v.Write("a.md", []byte("x"), Precondition{})
v.SaveAsset("a.md", "i.png", strings.NewReader("i"), 10) link, err := v.SaveAsset("a.md", "i.png", strings.NewReader("i"), 10)
if err != nil {
t.Fatal(err)
}
v.Mkdir("folder") v.Mkdir("folder")
os.WriteFile(filepath.Join(v.Root(), "note.txt"), []byte("x"), 0o644) os.WriteFile(filepath.Join(v.Root(), "note.txt"), []byte("x"), 0o644)
entries, err := v.Tree() entries, err := v.Tree()
@@ -235,7 +238,7 @@ func TestTreeHidesMetaAndAssets(t *testing.T) {
for _, e := range entries { for _, e := range entries {
paths = append(paths, e.Path) paths = append(paths, e.Path)
} }
if strings.Join(paths, ",") != "a.md,folder" { if strings.Join(paths, ",") != "a.assets,"+link+",a.md,folder" {
t.Fatalf("目录树不符: %v", paths) t.Fatalf("目录树不符: %v", paths)
} }
} }
+5
View File
@@ -203,6 +203,11 @@ func diff(prev, next map[string]fileState) []events.Event {
} }
case old.dir != cur.dir: case old.dir != cur.dir:
structural = true structural = true
case !cur.dir && cur.rev == "" && (old.size != cur.size || old.mtime != cur.mtime || !os.SameFile(old.info, cur.info)):
// 图片没有正文版本号;替换后仍需刷新列表元数据与预览。
if vault.IsImage(p) {
structural = true
}
case !cur.dir && old.rev != cur.rev: case !cur.dir && old.rev != cur.rev:
evs = append(evs, events.Event{Type: "changed", Path: p, Rev: cur.rev}) evs = append(evs, events.Event{Type: "changed", Path: p, Rev: cur.rev})
} }
+18
View File
@@ -170,3 +170,21 @@ func TestNotesExcludeResourceContents(t *testing.T) {
t.Fatalf("跟踪普通文件移动不能改变笔记索引范围: %v", indexed) t.Fatalf("跟踪普通文件移动不能改变笔记索引范围: %v", indexed)
} }
} }
func TestImageReplacementRefreshesTree(t *testing.T) {
root := t.TempDir()
p := filepath.Join(root, "a.png")
if err := os.WriteFile(p, []byte("image"), 0o644); err != nil {
t.Fatal(err)
}
info, err := os.Stat(p)
if err != nil {
t.Fatal(err)
}
old := fileState{info: info, size: 5, mtime: 1}
updated := fileState{info: info, size: 5, mtime: 2}
evs := diff(map[string]fileState{"a.png": old}, map[string]fileState{"a.png": updated})
if len(evs) != 1 || evs[0].Type != "tree" {
t.Fatalf("同路径同大小替换须刷新预览:%v", evs)
}
}
+1
View File
@@ -6,6 +6,7 @@ const paths = {
import: 'M12 3v12m-5-5 5 5 5-5M4 15v5h16v-5', import: 'M12 3v12m-5-5 5 5 5-5M4 15v5h16v-5',
folderAdd: 'M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM12 10v6M9 13h6', folderAdd: 'M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM12 10v6M9 13h6',
folder: 'M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z', folder: 'M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z',
image: 'M4 3h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM3 16l6-6 12 11M14 8h.01',
note: 'M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9zM14 3v6h6', note: 'M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9zM14 3v6h6',
chevron: 'm9 6 6 6-6 6', chevron: 'm9 6 6 6-6 6',
more: 'M12 6h.01M12 12h.01M12 18h.01', more: 'M12 6h.01M12 12h.01M12 18h.01',
+130
View File
@@ -0,0 +1,130 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { ApiError } from './api'
import Icon from './Icon.vue'
import { basename, dirname, isImage, joinPath } from './paths'
import { imageUrl, move, remove, replaceImage, ws } from './workspace'
const props = defineProps<{ path: string }>()
const dialog = ref<HTMLElement>()
const picker = ref<HTMLInputElement>()
const busy = ref(false)
const error = ref('')
const loadError = ref(false)
const dimensions = ref('')
const name = ref('')
const path = computed(() => props.path)
const entry = computed(() => ws.entries.find((e) => e.path === path.value))
const extension = computed(() => basename(path.value).slice(basename(path.value).lastIndexOf('.')))
const src = computed(() => imageUrl(path.value))
const siblings = computed(() => ws.entries.filter((e) => !e.dir && isImage(e.path) && dirname(e.path) === dirname(path.value)))
const index = computed(() => siblings.value.findIndex((e) => e.path === path.value))
const size = computed(() => entry.value ? (entry.value.size / 1024).toLocaleString('zh-CN', { maximumFractionDigits: 1 }) + ' KB' : '')
watch(path, (p) => { name.value = basename(p).slice(0, -extension.value.length); error.value = '' }, { immediate: true })
watch(src, () => { loadError.value = false; dimensions.value = '' })
onMounted(() => dialog.value?.focus())
function close() {
if (!busy.value) ws.image = null
}
function select(offset: number) {
const target = siblings.value[index.value + offset]
if (target && !busy.value) ws.image = target.path
}
function loaded(event: Event) {
const image = event.target as HTMLImageElement
dimensions.value = `${image.naturalWidth} × ${image.naturalHeight}`
}
async function rename() {
const base = name.value.trim()
if (!base || base.startsWith('.') || /[/\\]/.test(base)) { error.value = '图片名称不能为空、以点开头或包含斜杠'; return }
const to = joinPath(dirname(path.value), base + extension.value)
if (to === path.value || busy.value) return
busy.value = true
error.value = ''
try { await move(path.value, to) }
catch (err) { error.value = err instanceof Error ? err.message : '修改后同步失败,请检查目录' }
finally { busy.value = false }
}
async function replace(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file || busy.value) return
if (file.size > 50 * 1024 * 1024) { error.value = '图片不能超过 50 MB'; return }
if (!file.name.toLowerCase().endsWith(extension.value.toLowerCase())) { error.value = '请选择与原图片扩展名相同的文件'; return }
if (!confirm('替换会影响所有引用此路径的笔记,旧图片不会进入回收站。确定替换?')) return
busy.value = true
error.value = ''
try { await replaceImage(path.value, file) }
catch (err) { error.value = err instanceof ApiError ? err.message : '网络错误,替换结果尚未确认,请检查图片后再重试' }
finally { busy.value = false }
}
async function trash() {
if (busy.value || !confirm('将图片移入回收站?引用它的笔记会暂时无法显示此图片,恢复后可重新显示。')) return
busy.value = true
try { await remove(path.value) }
finally { busy.value = false }
}
</script>
<template>
<div class="rm-dialog-backdrop" @click.self="close" @keydown.esc.stop.prevent="close">
<section ref="dialog" class="rm-dialog rm-image-dialog" tabindex="-1" role="dialog" aria-modal="true" aria-label="图片管理" :aria-busy="busy">
<div class="rm-dialog-head">
<h2>图片管理</h2>
<button class="rm-icon-btn" title="关闭" :disabled="busy" @click="close"><Icon name="close" /></button>
</div>
<div class="rm-image-body">
<p class="rm-image-path">{{ path }}</p>
<div class="rm-image-preview">
<p v-if="!entry">图片已不存在,请刷新目录</p>
<p v-else-if="loadError" role="alert">图片无法预览,文件可能损坏或浏览器不支持此格式。</p>
<img v-else :key="src" :src="src" :alt="basename(path)" @load="loaded" @error="loadError = true" />
</div>
<div class="rm-image-navigation">
<button class="rm-btn" :disabled="busy || index <= 0" @click="select(-1)">上一张</button>
<span>{{ index + 1 }} / {{ siblings.length }} · {{ dimensions }} {{ size }}</span>
<button class="rm-btn" :disabled="busy || index >= siblings.length - 1" @click="select(1)">下一张</button>
</div>
<form class="rm-image-rename" @submit.prevent="rename">
<label for="rm-image-name">图片名称</label>
<div>
<input id="rm-image-name" v-model="name" :disabled="busy || !entry" required />
<span>{{ extension }}</span>
<button class="rm-btn" :disabled="busy || !entry || name.trim() + extension === basename(path)">重命名</button>
</div>
</form>
<p class="rm-image-hint">改名会更新笔记中的本地引用;替换保留原路径,请选择同格式图片(最大 50 MB)。</p>
<p v-if="error" class="rm-auth-error" role="alert">{{ error }}</p>
<div class="rm-image-actions">
<input ref="picker" type="file" :accept="extension" hidden :disabled="busy" @change="replace" />
<button class="rm-btn rm-btn-danger" :disabled="busy || !entry" @click="trash">移入回收站</button>
<button class="rm-btn rm-btn-primary" :disabled="busy || !entry" @click="picker?.click()">{{ busy ? '处理中…' : '替换图片' }}</button>
</div>
</div>
</section>
</div>
</template>
<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 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-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; }
</style>
+2 -2
View File
@@ -2,7 +2,7 @@
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { fuzzyMatch, segments } from './fuzzy' import { fuzzyMatch, segments } from './fuzzy'
import Icon from './Icon.vue' import Icon from './Icon.vue'
import { basename, dirname, joinPath, noteTitle } from './paths' import { basename, dirname, inAssetDir, joinPath, noteTitle } from './paths'
import { move, ws } from './workspace' import { move, ws } from './workspace'
// 「移动到…」:选择目标文件夹(手机上没有拖放,电脑上也比拖放快) // 「移动到…」:选择目标文件夹(手机上没有拖放,电脑上也比拖放快)
@@ -20,7 +20,7 @@ const title = computed(() => (isDir.value ? basename(props.path) : noteTitle(pro
const targets = computed(() => { const targets = computed(() => {
const here = dirname(props.path) const here = dirname(props.path)
const inside = (d: string) => isDir.value && (d === props.path || d.startsWith(props.path + '/')) const inside = (d: string) => isDir.value && (d === props.path || d.startsWith(props.path + '/'))
const dirs = ws.entries.filter((e) => e.dir && e.path !== here && !inside(e.path)).map((e) => e.path) const dirs = ws.entries.filter((e) => e.dir && !inAssetDir(e.path) && e.path !== here && !inside(e.path)).map((e) => e.path)
return here === '' ? dirs : ['', ...dirs] return here === '' ? dirs : ['', ...dirs]
}) })
+1 -1
View File
@@ -41,7 +41,7 @@ function cancel() {
function noteItems(q: string): Item[] { function noteItems(q: string): Item[] {
const recentRank = new Map(ws.recent.map((p, i) => [p, i])) const recentRank = new Map(ws.recent.map((p, i) => [p, i]))
const notes = ws.entries.filter((e) => !e.dir) const notes = ws.entries.filter((e) => !e.dir && e.path.endsWith(NOTE_EXT))
const toItem = (path: string, positions: number[] = []): Item => { const toItem = (path: string, positions: number[] = []): Item => {
const dir = dirname(path) const dir = dirname(path)
const offset = dir ? dir.length + 1 : 0 const offset = dir ? dir.length + 1 : 0
+2 -1
View File
@@ -2,6 +2,7 @@
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { api, type TrashItem } from './api' import { api, type TrashItem } from './api'
import Icon from './Icon.vue' import Icon from './Icon.vue'
import { isImage } from './paths'
import { restore, toast } from './workspace' import { restore, toast } from './workspace'
const items = ref<TrashItem[] | null>(null) const items = ref<TrashItem[] | null>(null)
@@ -41,7 +42,7 @@ onMounted(load)
<p v-else-if="items && !items.length" class="rm-tree-empty">回收站是空的</p> <p v-else-if="items && !items.length" class="rm-tree-empty">回收站是空的</p>
<ul v-else class="rm-trash-list"> <ul v-else class="rm-trash-list">
<li v-for="item in items ?? []" :key="item.id"> <li v-for="item in items ?? []" :key="item.id">
<Icon :name="item.dir ? 'folder' : 'note'" /> <Icon :name="item.dir ? 'folder' : isImage(item.path) ? 'image' : 'note'" />
<div class="rm-trash-info"> <div class="rm-trash-info">
<span class="rm-trash-path">{{ item.path.replace(/\.md$/, '') }}</span> <span class="rm-trash-path">{{ item.path.replace(/\.md$/, '') }}</span>
<span class="rm-trash-time">{{ fmt.format(item.deletedAt) }}</span> <span class="rm-trash-time">{{ fmt.format(item.deletedAt) }}</span>
+17 -12
View File
@@ -1,14 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue' import { computed, nextTick, ref, watch } from 'vue'
import Icon from './Icon.vue' import Icon from './Icon.vue'
import { basename, joinPath } from './paths' import { basename, inAssetDir, isImage, joinPath } from './paths'
import { expanded, type TreeNode } from './tree' import { expanded, type TreeNode } from './tree'
import { createFolder, createNote, move, openNote, remove, renameTarget, ws } from './workspace' import { createFolder, createNote, move, openNote, remove, renameTarget, ws } from './workspace'
const props = defineProps<{ node: TreeNode; depth: number }>() const props = defineProps<{ node: TreeNode; depth: number }>()
const image = computed(() => !props.node.dir && isImage(props.node.path))
const assetDir = computed(() => props.node.dir && inAssetDir(props.node.path))
const isOpen = computed(() => expanded.has(props.node.path)) const isOpen = computed(() => expanded.has(props.node.path))
const isActive = computed(() => ws.active === props.node.path) const isActive = computed(() => (image.value ? ws.image : ws.active) === props.node.path)
const isRenaming = computed(() => ws.renaming === props.node.path) const isRenaming = computed(() => ws.renaming === props.node.path)
const menuOpen = ref(false) const menuOpen = ref(false)
const dropTarget = ref(false) const dropTarget = ref(false)
@@ -48,7 +50,8 @@ function activate() {
if (props.node.dir) { if (props.node.dir) {
if (isOpen.value) expanded.delete(props.node.path) if (isOpen.value) expanded.delete(props.node.path)
else expanded.add(props.node.path) else expanded.add(props.node.path)
} else void openNote(props.node.path) } else if (image.value) ws.image = props.node.path
else void openNote(props.node.path)
} }
watch( watch(
@@ -94,7 +97,7 @@ function onDragStart(e: DragEvent) {
} }
function onDragOver(e: DragEvent) { function onDragOver(e: DragEvent) {
if (!props.node.dir || !e.dataTransfer?.types.includes('application/x-rawmark-path')) return if (!props.node.dir || assetDir.value || !e.dataTransfer?.types.includes('application/x-rawmark-path')) return
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
dropTarget.value = true dropTarget.value = true
@@ -103,7 +106,7 @@ function onDragOver(e: DragEvent) {
function onDrop(e: DragEvent) { function onDrop(e: DragEvent) {
dropTarget.value = false dropTarget.value = false
const src = e.dataTransfer?.getData('application/x-rawmark-path') const src = e.dataTransfer?.getData('application/x-rawmark-path')
if (!props.node.dir || !src) return if (!props.node.dir || assetDir.value || !src) return
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
const to = joinPath(props.node.path, basename(src)) const to = joinPath(props.node.path, basename(src))
@@ -117,7 +120,7 @@ function onDrop(e: DragEvent) {
class="rm-tree-row" class="rm-tree-row"
:class="{ active: isActive, drop: dropTarget, 'menu-open': menuOpen }" :class="{ active: isActive, drop: dropTarget, 'menu-open': menuOpen }"
:style="{ paddingLeft: depth * 14 + 8 + 'px' }" :style="{ paddingLeft: depth * 14 + 8 + 'px' }"
:draggable="!isRenaming" :draggable="!isRenaming && !assetDir"
@click="activate" @click="activate"
@contextmenu.prevent="menuOpen = true" @contextmenu.prevent="menuOpen = true"
@touchstart.passive="onTouchStart" @touchstart.passive="onTouchStart"
@@ -140,20 +143,22 @@ function onDrop(e: DragEvent) {
@keydown.esc.prevent="cancelRename" @keydown.esc.prevent="cancelRename"
@blur="commitRename" @blur="commitRename"
/> />
<span v-else class="rm-tree-name">{{ node.dir ? node.name : node.name.replace(/\.md$/, '') }}</span> <Icon v-if="image" name="image" :size="14" />
<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"> <button class="rm-icon-btn rm-tree-more" title="更多操作" @click.stop="menuOpen = !menuOpen">
<Icon name="more" /> <Icon name="more" />
</button> </button>
<div v-if="menuOpen" class="rm-menu" @click.stop> <div v-if="menuOpen" class="rm-menu" @click.stop>
<template v-if="node.dir"> <template v-if="node.dir && !assetDir">
<button @click="runMenu('note')"><Icon name="noteAdd" />新建笔记</button> <button @click="runMenu('note')"><Icon name="noteAdd" />新建笔记</button>
<button @click="runMenu('folder')"><Icon name="folderAdd" />新建文件夹</button> <button @click="runMenu('folder')"><Icon name="folderAdd" />新建文件夹</button>
<button @click="runMenu('import')"><Icon name="import" />导入</button> <button @click="runMenu('import')"><Icon name="import" />导入</button>
</template> </template>
<button @click="runMenu('rename')"><Icon name="edit" />重命名</button> <button v-if="image" @click="menuOpen = false; ws.image = node.path"><Icon name="image" />预览 / 替换图片</button>
<button @click="runMenu('move')"><Icon name="folder" />移动到…</button> <button v-if="!assetDir" @click="runMenu('rename')"><Icon name="edit" />重命名</button>
<button @click="runMenu('share')"><Icon name="link" />分享</button> <button v-if="!assetDir" @click="runMenu('move')"><Icon name="folder" />移动到…</button>
<button class="danger" @click="runMenu('delete')"><Icon name="trash" />删除</button> <button v-if="!image" @click="runMenu('share')"><Icon name="link" />分享</button>
<button v-if="!assetDir" class="danger" @click="runMenu('delete')"><Icon name="trash" />删除</button>
</div> </div>
<div v-if="menuOpen" class="rm-menu-backdrop" @click.stop="menuOpen = false" @contextmenu.prevent.stop="menuOpen = false" /> <div v-if="menuOpen" class="rm-menu-backdrop" @click.stop="menuOpen = false" @contextmenu.prevent.stop="menuOpen = false" />
</div> </div>
+2
View File
@@ -7,6 +7,7 @@ import Icon from './Icon.vue'
import MobileToolbar from './MobileToolbar.vue' import MobileToolbar from './MobileToolbar.vue'
import MoveDialog from './MoveDialog.vue' import MoveDialog from './MoveDialog.vue'
import ImportDialog from './ImportDialog.vue' import ImportDialog from './ImportDialog.vue'
import ImageDialog from './ImageDialog.vue'
import NoteBanner from './NoteBanner.vue' import NoteBanner from './NoteBanner.vue'
import OutlinePanel from './OutlinePanel.vue' import OutlinePanel from './OutlinePanel.vue'
import QuickSwitcher from './QuickSwitcher.vue' import QuickSwitcher from './QuickSwitcher.vue'
@@ -122,6 +123,7 @@ onBeforeUnmount(() => {
<SettingsDialog v-if="ws.settings" /> <SettingsDialog v-if="ws.settings" />
<ShareDialog v-if="ws.sharing" :path="ws.sharing" @close="ws.sharing = null" /> <ShareDialog v-if="ws.sharing" :path="ws.sharing" @close="ws.sharing = null" />
<ImportDialog v-if="ws.importing !== null" :dir="ws.importing" @close="ws.importing = null" /> <ImportDialog v-if="ws.importing !== null" :dir="ws.importing" @close="ws.importing = null" />
<ImageDialog v-if="ws.image" :path="ws.image" />
<MoveDialog v-if="ws.moving" :path="ws.moving" @close="ws.moving = null" /> <MoveDialog v-if="ws.moving" :path="ws.moving" @close="ws.moving = null" />
</div> </div>
</template> </template>
+6
View File
@@ -174,6 +174,12 @@ export const api = {
return (await json<{ link: string }>(request('POST', '/api/v1/assets?note=' + encodeURIComponent(note), { body: form }))).link return (await json<{ link: string }>(request('POST', '/api/v1/assets?note=' + encodeURIComponent(note), { body: form }))).link
}, },
replaceImage: (path: string, file: File) => {
const body = new FormData()
body.append('file', file)
return request('PUT', '/api/v1/assets/' + encodePath(path), { body })
},
importImage: async (note: string, url: string): Promise<string> => importImage: async (note: string, url: string): Promise<string> =>
(await json<{ link: string }>(request('POST', '/api/v1/assets/import?note=' + encodeURIComponent(note), { json: { url } }))).link, (await json<{ link: string }>(request('POST', '/api/v1/assets/import?note=' + encodeURIComponent(note), { json: { url } }))).link,
+8
View File
@@ -2,6 +2,14 @@ import { encodePath } from './api'
export const NOTE_EXT = '.md' export const NOTE_EXT = '.md'
export function isImage(path: string): boolean {
return /\.(png|jpe?g|gif|webp|svg|avif|apng|bmp|ico|tiff?)$/i.test(path)
}
export function inAssetDir(path: string): boolean {
return path.split('/').some((part) => part.endsWith('.assets'))
}
export function dirname(path: string): string { export function dirname(path: string): string {
const i = path.lastIndexOf('/') const i = path.lastIndexOf('/')
return i < 0 ? '' : path.slice(0, i) return i < 0 ? '' : path.slice(0, i)
+1 -1
View File
@@ -37,7 +37,7 @@ watch(expanded, () => localStorage.setItem(EXPANDED_KEY, JSON.stringify([...expa
// 打开或新建笔记时展开其所在的各级目录 // 打开或新建笔记时展开其所在的各级目录
watch( watch(
() => [ws.active, ws.renaming], () => [ws.active, ws.renaming, ws.image],
(paths) => { (paths) => {
for (const p of paths) { for (const p of paths) {
for (let d = p ? dirname(p) : ''; d; d = dirname(d)) expanded.add(d) for (let d = p ? dirname(p) : ''; d; d = dirname(d)) expanded.add(d)
+32 -1
View File
@@ -6,7 +6,7 @@ import { api, ApiError } from './api'
import { drafts, type Draft } from './drafts' import { drafts, type Draft } from './drafts'
import { connectEvents } from './events' import { connectEvents } from './events'
import { pluginEvents } from '../plugins/registry' import { pluginEvents } from '../plugins/registry'
import { attachView, closeTab, editorStateOf, flush, keepLocalVersion, importNotes, move, readyToReload, openNote, refreshTree, startWorkspace, stopWorkspace, storeViewState, ws } from './workspace' import { attachView, closeTab, editorStateOf, flush, keepLocalVersion, importNotes, move, readyToReload, openNote, refreshTree, replaceImage, restore, startWorkspace, stopWorkspace, storeViewState, ws } from './workspace'
vi.mock('./events', () => ({ connectEvents: vi.fn(() => () => {}) })) vi.mock('./events', () => ({ connectEvents: vi.fn(() => () => {}) }))
@@ -275,3 +275,34 @@ describe('导入', () => {
expect(readyToReload()).toBe(true) expect(readyToReload()).toBe(true)
}) })
}) })
describe('图片管理', () => {
it('替换期间阻止刷新,完成后更新列表;刷新失败不伪装成替换失败', async () => {
ws.tabs = []
let finish!: (value: Response) => void
vi.spyOn(api, 'replaceImage').mockImplementation(() => new Promise((resolve) => { finish = resolve }))
vi.spyOn(api, 'tree').mockResolvedValue([{ path: 'assets/a.png', dir: false, size: 2, mtime: 2 }])
const replacing = replaceImage('assets/a.png', new File(['image'], 'a.png'))
expect(readyToReload()).toBe(false)
finish(new Response())
await replacing
expect(ws.entries.find((e) => e.path === 'assets/a.png')?.mtime).toBe(2)
expect(readyToReload()).toBe(true)
vi.mocked(api.replaceImage).mockResolvedValueOnce(new Response())
vi.mocked(api.tree).mockRejectedValueOnce(new ApiError(500, '读取失败'))
await replaceImage('assets/a.png', new File(['image'], 'a.png'))
expect(ws.toasts.some((t) => t.text.includes('图片已替换,但刷新目录失败'))).toBe(true)
})
it('恢复图片只打开预览,不创建 Markdown 标签', async () => {
ws.tabs = []
ws.active = null
vi.spyOn(api, 'restore').mockResolvedValue({ id: 'trash-image', path: 'assets/a.png', dir: false, deletedAt: 1 })
vi.spyOn(api, 'tree').mockResolvedValue([{ path: 'assets/a.png', dir: false, size: 2, mtime: 2 }])
await restore('trash-image')
expect(ws.image).toBe('assets/a.png')
expect(ws.tabs).toEqual([])
expect(ws.active).toBeNull()
})
})
+46 -9
View File
@@ -3,12 +3,13 @@
import { Annotation, EditorState, Transaction } from '@codemirror/state' import { Annotation, EditorState, Transaction } from '@codemirror/state'
import { EditorView } from '@codemirror/view' import { EditorView } from '@codemirror/view'
import { nextTick, reactive, watch } from 'vue' import { nextTick, reactive, watch } from 'vue'
import { refreshImages } from '../editor/livepreview'
import { createEditorState, insertImageFiles, pasteImages, languageCompartment, pluginCompartment, pluginEditorExtensions, pluginLanguage, preferencesCompartment } from '../editor' import { createEditorState, insertImageFiles, pasteImages, languageCompartment, pluginCompartment, pluginEditorExtensions, pluginLanguage, preferencesCompartment } from '../editor'
import { handleShortcut, registerCommand } from './commands' import { handleShortcut, registerCommand } from './commands'
import { api, ApiError, type Entry } from './api' import { api, ApiError, encodePath, type Entry } from './api'
import { drafts } from './drafts' import { drafts } from './drafts'
import { connectEvents, type VaultEvent } from './events' import { connectEvents, type VaultEvent } from './events'
import { basename, dirname, joinPath, NOTE_EXT, resolveAssetUrl } from './paths' import { basename, dirname, joinPath, isImage, NOTE_EXT, resolveAssetUrl } from './paths'
import { loadSettings, settings } from './settings' import { loadSettings, settings } from './settings'
import { trackVisualViewport } from './viewport' import { trackVisualViewport } from './viewport'
import { appearancePaused } from '../themes/appearance' import { appearancePaused } from '../themes/appearance'
@@ -64,6 +65,8 @@ export const ws = reactive({
moving: null as string | null, moving: null as string | null,
// 导入目标目录,空字符串为根目录 // 导入目标目录,空字符串为根目录
importing: null as string | null, importing: null as string | null,
// 图片预览与管理,独立于笔记标签和草稿
image: null as string | null,
// 电脑上侧栏折叠 // 电脑上侧栏折叠
sidebarCollapsed: layout.sidebarCollapsed ?? false, sidebarCollapsed: layout.sidebarCollapsed ?? false,
// 大纲面板(电脑上在右侧常驻,手机上为浮层) // 大纲面板(电脑上在右侧常驻,手机上为浮层)
@@ -180,7 +183,11 @@ function buildState(path: string, doc: string): EditorState {
doc, doc,
spellcheck: settings.spellcheck, spellcheck: settings.spellcheck,
tabSize: settings.tabSize, tabSize: settings.tabSize,
resolveImage: (src) => resolveAssetUrl(ref.path, src), resolveImage: (src) => {
const url = resolveAssetUrl(ref.path, src)
const entry = ws.entries.find((e) => url === '/api/v1/raw/' + encodePath(e.path))
return entry ? imageUrl(entry.path) : url
},
onChange: (u) => { onChange: (u) => {
editorStates.set(ref.path, u.state) editorStates.set(ref.path, u.state)
if (!u.transactions.some((tr) => tr.annotation(externalChange))) scheduleSave(ref.path) if (!u.transactions.some((tr) => tr.annotation(externalChange))) scheduleSave(ref.path)
@@ -591,7 +598,11 @@ async function fetchTree() {
if (opsInFlight === 0) scheduleTreeRefresh() if (opsInFlight === 0) scheduleTreeRefresh()
return return
} }
const oldImages = new Map(ws.entries.filter((e) => !e.dir && isImage(e.path)).map((e) => [e.path, e]))
const nextPaths = new Set(entries.map((e) => e.path))
const imagesChanged = entries.some((e) => !e.dir && isImage(e.path) && (e.mtime !== oldImages.get(e.path)?.mtime || e.size !== oldImages.get(e.path)?.size)) || [...oldImages.keys()].some((p) => !nextPaths.has(p))
ws.entries = entries ws.entries = entries
if (imagesChanged) view?.dispatch({ effects: refreshImages.of(null) })
markMissingTabsDeleted() markMissingTabsDeleted()
for (const waiter of treeWaiters.splice(0)) waiter.resolve() for (const waiter of treeWaiters.splice(0)) waiter.resolve()
} }
@@ -697,7 +708,7 @@ export function renameTarget(path: string, name: string): string | null {
name = name.trim().replace(/\//g, '') name = name.trim().replace(/\//g, '')
if (!name || name.startsWith('.')) return null if (!name || name.startsWith('.')) return null
const entry = ws.entries.find((e) => e.path === path) const entry = ws.entries.find((e) => e.path === path)
if (entry && !entry.dir && !name.endsWith(NOTE_EXT)) name += NOTE_EXT if (entry && !entry.dir && path.endsWith(NOTE_EXT) && !name.endsWith(NOTE_EXT)) name += NOTE_EXT
return joinPath(dirname(path), name) return joinPath(dirname(path), name)
} }
@@ -709,19 +720,26 @@ export async function move(from: string, to: string) {
return return
} }
await withOp(async () => { await withOp(async () => {
const busy = () => affected(from).some((p) => slots.get(p)?.uploads || slots.get(p)?.paused) // 图片改名会更新仓库中的引用,先保存已打开笔记,沿用版本冲突保护。
const imageMove = isImage(from) && ws.entries.some((e) => e.path === from && !e.dir)
const impacted = () => imageMove ? ws.tabs.map((t) => t.path) : affected(from)
const busy = () => impacted().some((p) => slots.get(p)?.uploads || slots.get(p)?.paused)
if (busy()) { if (busy()) {
toast('笔记正在处理粘贴、上传或移动,请完成后再移动') toast('笔记正在处理粘贴、上传或移动,请完成后再移动')
return return
} }
// 先保存待保存的修改,确保移动的是最新内容;移动期间暂停保存,避免写到旧路径 // 先保存待保存的修改,确保移动的是最新内容;移动期间暂停保存,避免写到旧路径
await Promise.all(affected(from).map((p) => flush(p))) await Promise.all(impacted().map((p) => flush(p)))
// 等待保存时仍可能开始粘贴,须在取得移动占用前再次检查。 // 等待保存时仍可能开始粘贴,须在取得移动占用前再次检查。
if (busy()) { if (busy()) {
toast('笔记正在处理粘贴、上传或移动,请完成后再移动') toast('笔记正在处理粘贴、上传或移动,请完成后再移动')
return return
} }
const paused = affected(from).map(slot) if (imageMove && ws.tabs.some((t) => t.dirty || t.status !== 'ready')) {
toast('请先保存笔记并解决冲突,再修改图片名称或位置')
return
}
const paused = impacted().map(slot)
paused.forEach((s) => s.paused++) paused.forEach((s) => s.paused++)
const snapshot = ws.entries const snapshot = ws.entries
let draftsMoved = false let draftsMoved = false
@@ -734,12 +752,14 @@ export async function move(from: string, to: string) {
ws.entries = ws.entries.map((e) => ({ ...e, path: remapPath(e.path, from, to) ?? e.path })) ws.entries = ws.entries.map((e) => ({ ...e, path: remapPath(e.path, from, to) ?? e.path }))
remapOpenNotes(from, to) remapOpenNotes(from, to)
remapRecent(from, to) remapRecent(from, to)
if (ws.image) ws.image = remapPath(ws.image, from, to) ?? ws.image
await api.move(from, to) await api.move(from, to)
} catch (err) { } catch (err) {
if (remapped) { if (remapped) {
ws.entries = snapshot ws.entries = snapshot
remapOpenNotes(to, from) remapOpenNotes(to, from)
remapRecent(to, from) remapRecent(to, from)
if (ws.image) ws.image = remapPath(ws.image, to, from) ?? ws.image
} }
if (draftsMoved) { if (draftsMoved) {
try { await drafts.move(to, from) } try { await drafts.move(to, from) }
@@ -751,7 +771,7 @@ export async function move(from: string, to: string) {
paused.forEach((s) => s.paused--) paused.forEach((s) => s.paused--)
} }
// 笔记改名时服务器会改写正文中的图片链接,版本随之变化,需要重新同步 // 笔记改名时服务器会改写正文中的图片链接,版本随之变化,需要重新同步
await Promise.all(affected(to).map(async (p) => { await Promise.all((imageMove ? ws.tabs.map((t) => t.path) : affected(to)).map(async (p) => {
const tab = tabOf(p) const tab = tabOf(p)
if (!tab) return if (!tab) return
if (tab.dirty) { if (tab.dirty) {
@@ -777,6 +797,7 @@ export async function remove(path: string) {
} }
try { try {
const item = await api.deleteFile(path) const item = await api.deleteFile(path)
if (ws.image && remapPath(ws.image, path, path) !== null) ws.image = null
toast(`已删除“${basename(path).replace(/\.md$/, '')}”`, { toast(`已删除“${basename(path).replace(/\.md$/, '')}”`, {
label: '撤销', label: '撤销',
run: () => void restore(item.id), run: () => void restore(item.id),
@@ -792,12 +813,25 @@ export async function restore(id: string) {
try { try {
const item = await api.restore(id) const item = await api.restore(id)
await refreshTree() await refreshTree()
if (!item.dir) void openNote(item.path) if (!item.dir && item.path.endsWith(NOTE_EXT)) void openNote(item.path)
else if (!item.dir && isImage(item.path)) ws.image = item.path
} catch (err) { } catch (err) {
toast('恢复失败:' + errorText(err)) toast('恢复失败:' + errorText(err))
} }
} }
// 版本只用于刷新浏览器图片,不改变 Markdown 中保存的相对路径。
export function imageUrl(path: string): string {
const entry = ws.entries.find((e) => e.path === path)
return '/api/v1/raw/' + encodePath(path) + (entry ? `?v=${entry.mtime}-${entry.size}` : '')
}
export async function replaceImage(path: string, file: File) {
await withOp(() => api.replaceImage(path, file))
try { await refreshTree() }
catch (err) { toast('图片已替换,但刷新目录失败:' + errorText(err)) }
}
// ---- 实时同步 ---- // ---- 实时同步 ----
async function syncOpenTab(tab: Tab) { async function syncOpenTab(tab: Tab) {
@@ -828,6 +862,7 @@ async function handleEvents(evs: VaultEvent[]) {
// 自己保存产生的事件 rev 与本地一致;有本地修改时不覆盖,交由下次保存的冲突检测处理 // 自己保存产生的事件 rev 与本地一致;有本地修改时不覆盖,交由下次保存的冲突检测处理
if (tab && tab.rev !== ev.rev) await syncOpenTab(tab) if (tab && tab.rev !== ev.rev) await syncOpenTab(tab)
} else if (ev.type === 'moved') { } else if (ev.type === 'moved') {
if (ws.image) ws.image = remapPath(ws.image, ev.from!, ev.path!) ?? ws.image
const paused = affected(ev.from!).map(slot) const paused = affected(ev.from!).map(slot)
paused.forEach((s) => s.paused++) paused.forEach((s) => s.paused++)
try { try {
@@ -842,6 +877,7 @@ async function handleEvents(evs: VaultEvent[]) {
remapRecent(ev.from!, ev.path!) remapRecent(ev.from!, ev.path!)
} finally { paused.forEach((s) => s.paused--) } } finally { paused.forEach((s) => s.paused--) }
} else if (ev.type === 'deleted') { } else if (ev.type === 'deleted') {
if (ws.image === ev.path && opsInFlight === 0) ws.image = null
const tab = tabOf(ev.path!) const tab = tabOf(ev.path!)
if (tab && tab.status === 'ready' && opsInFlight === 0) { if (tab && tab.status === 'ready' && opsInFlight === 0) {
tab.status = 'deleted' tab.status = 'deleted'
@@ -1053,6 +1089,7 @@ export function openSearch() {
} }
export function stopWorkspace() { export function stopWorkspace() {
ws.image = null
stopPlugins() stopPlugins()
disconnect?.() disconnect?.()
disconnect = undefined disconnect = undefined
+5 -1
View File
@@ -1,5 +1,5 @@
import { syntaxTree } from '@codemirror/language' import { syntaxTree } from '@codemirror/language'
import { Facet, type EditorState, type Range } from '@codemirror/state' import { StateEffect, Facet, type EditorState, type Range } from '@codemirror/state'
import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from '@codemirror/view' import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from '@codemirror/view'
import type { SyntaxNode } from '@lezer/common' import type { SyntaxNode } from '@lezer/common'
import { codeContentRange, fenceLanguage, fencedCodeAt } from './codeblocks' import { codeContentRange, fenceLanguage, fencedCodeAt } from './codeblocks'
@@ -21,6 +21,9 @@ export const resolveImageUrl = Facet.define<(src: string) => string, (src: strin
combine: (values) => values[0] ?? ((src) => src), combine: (values) => values[0] ?? ((src) => src),
}) })
// 图片文件原位替换时重建预览,不改变正文、选区或撤销历史。
export const refreshImages = StateEffect.define<null>()
const hidden = Decoration.replace({}) const hidden = Decoration.replace({})
const inlineCode = Decoration.mark({ class: 'cm-rm-inline-code' }) const inlineCode = Decoration.mark({ class: 'cm-rm-inline-code' })
const linkText = Decoration.mark({ class: 'cm-rm-link' }) const linkText = Decoration.mark({ class: 'cm-rm-link' })
@@ -328,6 +331,7 @@ const livePreviewPlugin = ViewPlugin.fromClass(
} }
if ( if (
this.stale || this.stale ||
u.transactions.some((tr) => tr.effects.some((effect) => effect.is(refreshImages))) ||
u.docChanged || u.docChanged ||
u.viewportChanged || u.viewportChanged ||
u.selectionSet || u.selectionSet ||