feat(tree): persist custom node icons and animate folder expansion
This commit is contained in:
1 parent
4a8902a3f6
commit
0861be681b
18 files changed
+1054
-70
No files matched your search
@@ -52,6 +52,8 @@ docker run -d --name rawmark \
|
|||||||
| `RAWMARK_VAULT` | `$RAWMARK_DATA/vault` | 笔记仓库目录,可指向已有的笔记文件夹 |
|
| `RAWMARK_VAULT` | `$RAWMARK_DATA/vault` | 笔记仓库目录,可指向已有的笔记文件夹 |
|
||||||
| `RAWMARK_ADDR` | `:8080` | 监听地址 |
|
| `RAWMARK_ADDR` | `:8080` | 监听地址 |
|
||||||
|
|
||||||
|
文件树默认使用 📂 / 📄,可点击图标或右键“修改图标”,选择表情或内置线性图标。自定义属性存于仓库的 `.rawmark/tree-icons.json`,随应用内移动、重命名及回收站恢复保留,不改动或导出到 Markdown 正文;外部文件操作不保证跟随。
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
需要 Go 1.27+ 与 Node 24+。开发时不必构建前端或 Docker 镜像,一键启动:
|
需要 Go 1.27+ 与 Node 24+。开发时不必构建前端或 Docker 镜像,一键启动:
|
||||||
|
|||||||
+27
-1
@@ -76,6 +76,7 @@ func (s *Server) routes() []route {
|
|||||||
{"POST /api/v1/auth/password", sessionOnly, s.changePassword},
|
{"POST /api/v1/auth/password", sessionOnly, s.changePassword},
|
||||||
|
|
||||||
{"GET /api/v1/tree", authed, s.tree},
|
{"GET /api/v1/tree", authed, s.tree},
|
||||||
|
{"PUT /api/v1/tree-icon", authed, s.setTreeIcon},
|
||||||
{"GET /api/v1/metadata", authed, s.metadata},
|
{"GET /api/v1/metadata", authed, s.metadata},
|
||||||
{"POST /api/v1/export", authed, s.export},
|
{"POST /api/v1/export", authed, s.export},
|
||||||
{"GET /api/v1/files/{path...}", authed, s.readFile},
|
{"GET /api/v1/files/{path...}", authed, s.readFile},
|
||||||
@@ -290,6 +291,27 @@ func (s *Server) tree(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, 200, map[string]any{"entries": entries})
|
writeJSON(w, 200, map[string]any{"entries": entries})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) setTreeIcon(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Icon json.RawMessage `json:"icon"`
|
||||||
|
}
|
||||||
|
if !readJSON(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var icon *vault.TreeIcon
|
||||||
|
if len(body.Icon) == 0 || json.Unmarshal(body.Icon, &icon) != nil {
|
||||||
|
writeError(w, 400, "请求格式错误:需要 icon(null 恢复默认)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.vault.SetTreeIcon(body.Path, icon); err != nil {
|
||||||
|
vaultError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.hub.Broadcast(events.Event{Type: "tree"})
|
||||||
|
writeJSON(w, 200, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
const searchLimit = 50
|
const searchLimit = 50
|
||||||
|
|
||||||
func (s *Server) search(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) search(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -355,6 +377,9 @@ func (s *Server) move(w http.ResponseWriter, r *http.Request) {
|
|||||||
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"})
|
||||||
|
} else {
|
||||||
|
// 图标提交在文件移动之后;不能仅依赖文件监听器较早的通知。
|
||||||
|
s.hub.Broadcast(events.Event{Type: "tree"})
|
||||||
}
|
}
|
||||||
writeJSON(w, 200, map[string]bool{"ok": true})
|
writeJSON(w, 200, map[string]bool{"ok": true})
|
||||||
}
|
}
|
||||||
@@ -461,6 +486,7 @@ func (s *Server) trashRestore(w http.ResponseWriter, r *http.Request) {
|
|||||||
vaultError(w, r, err)
|
vaultError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.hub.Broadcast(events.Event{Type: "tree"})
|
||||||
writeJSON(w, 200, item)
|
writeJSON(w, 200, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,7 +537,7 @@ func vaultError(w http.ResponseWriter, r *http.Request, err error) {
|
|||||||
switch {
|
switch {
|
||||||
case errors.As(err, &conflict):
|
case errors.As(err, &conflict):
|
||||||
writeJSON(w, 412, map[string]string{"error": err.Error(), "rev": conflict.Rev})
|
writeJSON(w, 412, map[string]string{"error": err.Error(), "rev": conflict.Rev})
|
||||||
case errors.Is(err, vault.ErrInvalidPath), errors.Is(err, vault.ErrInvalidImport):
|
case errors.Is(err, vault.ErrInvalidPath), errors.Is(err, vault.ErrInvalidImport), errors.Is(err, vault.ErrInvalidIcon):
|
||||||
writeError(w, 400, err.Error())
|
writeError(w, 400, err.Error())
|
||||||
case errors.Is(err, vault.ErrNotFound):
|
case errors.Is(err, vault.ErrNotFound):
|
||||||
writeError(w, 404, err.Error())
|
writeError(w, 404, err.Error())
|
||||||
|
|||||||
@@ -1967,6 +1967,61 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/tree-icon": {
|
||||||
|
"put": {
|
||||||
|
"summary": "设置单个文件或目录的图标,null 恢复默认",
|
||||||
|
"tags": [
|
||||||
|
"文件"
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"path",
|
||||||
|
"icon"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"path": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"icon": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/TreeIcon"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "成功",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "非法路径或图标"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "文件不存在"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
@@ -2018,6 +2073,9 @@
|
|||||||
"mtime": {
|
"mtime": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "修改时间(Unix 毫秒)"
|
"description": "修改时间(Unix 毫秒)"
|
||||||
|
},
|
||||||
|
"icon": {
|
||||||
|
"$ref": "#/components/schemas/TreeIcon"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2155,6 +2213,26 @@
|
|||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"TreeIcon": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"kind",
|
||||||
|
"value"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"emoji",
|
||||||
|
"builtin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Emoji 字符或内置线性图标名称,最多64字节"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"rawmark/internal/events"
|
||||||
|
"rawmark/internal/vault"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTreeIconAuthAndBroadcast(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
f.write("a.md", "正文")
|
||||||
|
payload := `{"path":"a.md","icon":{"kind":"emoji","value":"📚"}}`
|
||||||
|
check := func(client *http.Client, headers map[string]string, want int) {
|
||||||
|
t.Helper()
|
||||||
|
res := f.do(client, "PUT", "/api/v1/tree-icon", payload, headers)
|
||||||
|
res.Body.Close()
|
||||||
|
if res.StatusCode != want {
|
||||||
|
t.Fatalf("设置图标状态: %d,期望 %d", res.StatusCode, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check(newClient(), nil, 401)
|
||||||
|
for _, scope := range []string{"read", "write"} {
|
||||||
|
token := decode[struct{ Token string }](t, f.do(f.admin, "POST", "/api/v1/tokens", `{"name":"icons","scope":"`+scope+`"}`, nil))
|
||||||
|
want := 200
|
||||||
|
if scope == "read" {
|
||||||
|
want = 403
|
||||||
|
}
|
||||||
|
check(newClient(), map[string]string{"Authorization": "Bearer " + token.Token}, want)
|
||||||
|
}
|
||||||
|
ch, cancel := f.hub.Subscribe()
|
||||||
|
defer cancel()
|
||||||
|
check(f.admin, nil, 200)
|
||||||
|
select {
|
||||||
|
case msg := <-ch:
|
||||||
|
var batch []events.Event
|
||||||
|
if err := json.Unmarshal(msg, &batch); err != nil || len(batch) != 1 || batch[0].Type != "tree" {
|
||||||
|
t.Fatalf("图标广播不符: %s %v", msg, err)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("缺少 tree 广播")
|
||||||
|
}
|
||||||
|
tree := decode[struct{ Entries []vault.Entry }](t, f.do(f.admin, "GET", "/api/v1/tree", "", nil))
|
||||||
|
if len(tree.Entries) != 1 || tree.Entries[0].Icon == nil || tree.Entries[0].Icon.Value != "📚" {
|
||||||
|
t.Fatalf("目录属性不符: %+v", tree)
|
||||||
|
}
|
||||||
|
res := f.do(f.admin, "PUT", "/api/v1/tree-icon", `{"path":"a.md","icon":null}`, nil)
|
||||||
|
res.Body.Close()
|
||||||
|
if res.StatusCode != 200 {
|
||||||
|
t.Fatal(res.StatusCode)
|
||||||
|
}
|
||||||
|
tree = decode[struct{ Entries []vault.Entry }](t, f.do(f.admin, "GET", "/api/v1/tree", "", nil))
|
||||||
|
if tree.Entries[0].Icon != nil {
|
||||||
|
t.Fatal("恢复默认后不应返回覆盖属性")
|
||||||
|
}
|
||||||
|
for _, body := range []string{`{"path":"a.md"}`, `{"path":"a.md","icon":"📚"}`, `{"path":"a.md","icon":{"kind":"builtin","value":"fake"}}`, `{"path":"../a.md","icon":null}`} {
|
||||||
|
res := f.do(f.admin, "PUT", "/api/v1/tree-icon", body, nil)
|
||||||
|
res.Body.Close()
|
||||||
|
if res.StatusCode != 400 {
|
||||||
|
t.Fatal(res.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package vault
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TreeIcon 是文件树的显示属性,不参与 Markdown 内容或版本计算。
|
||||||
|
type TreeIcon struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrInvalidIcon = errors.New("非法图标")
|
||||||
|
|
||||||
|
const builtinIcons = " plus eye expand graph calendar tag copy task quote undo redo print grid menu noteAdd import folderAdd folder image note chevron more close pin trash logout restore edit search link key settings palette user outline source command info "
|
||||||
|
|
||||||
|
var emojiPattern = regexp.MustCompile(`^[\p{So}\p{Sk}\p{Sm}\x{2139}\x{203C}\x{2049}\x{3030}\x{303D}\x{200D}\x{FE0F}\x{20E3}0-9#*\x{E0020}-\x{E007F}]+$`)
|
||||||
|
var emojiBase = regexp.MustCompile(`[\p{So}\p{Sm}\x{2139}\x{203C}\x{2049}\x{3030}\x{303D}\x{20E3}]`)
|
||||||
|
|
||||||
|
func (icon TreeIcon) valid() bool {
|
||||||
|
switch icon.Kind {
|
||||||
|
case "builtin":
|
||||||
|
return icon.Value != "" && !strings.ContainsAny(icon.Value, " \t\r\n") && strings.Contains(builtinIcons, " "+icon.Value+" ")
|
||||||
|
case "emoji":
|
||||||
|
return utf8.ValidString(icon.Value) && len(icon.Value) <= 64 && emojiPattern.MatchString(icon.Value) && emojiBase.MatchString(icon.Value)
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Vault) readTreeIcons() (map[string]TreeIcon, error) {
|
||||||
|
data, err := os.ReadFile(filepath.Join(v.root, metaDir, "tree-icons.json"))
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return map[string]TreeIcon{}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var icons map[string]TreeIcon
|
||||||
|
if err := json.Unmarshal(data, &icons); err != nil {
|
||||||
|
return nil, fmt.Errorf("文件树图标元数据损坏: %w", err)
|
||||||
|
}
|
||||||
|
if icons == nil {
|
||||||
|
return nil, errors.New("文件树图标元数据必须是对象")
|
||||||
|
}
|
||||||
|
for p, icon := range icons {
|
||||||
|
if _, err := CleanPath(p); err != nil || !icon.valid() {
|
||||||
|
return nil, errors.New("文件树图标元数据含非法路径或图标")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return icons, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Vault) writeTreeIcons(icons map[string]TreeIcon) error {
|
||||||
|
data, err := json.Marshal(icons)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return atomicWrite(filepath.Join(v.root, metaDir, "tree-icons.json"), data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Vault) SetTreeIcon(rel string, icon *TreeIcon) error {
|
||||||
|
if icon != nil && !icon.valid() {
|
||||||
|
return ErrInvalidIcon
|
||||||
|
}
|
||||||
|
p, err := v.abs(rel)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
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.IsDir() && (!info.Mode().IsRegular() || (!IsNote(rel) && !IsImage(rel))) {
|
||||||
|
return ErrInvalidPath
|
||||||
|
}
|
||||||
|
icons, err := v.readTreeIcons()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if icon == nil {
|
||||||
|
if _, ok := icons[rel]; !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
delete(icons, rel)
|
||||||
|
} else {
|
||||||
|
if icons[rel] == *icon {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
icons[rel] = *icon
|
||||||
|
}
|
||||||
|
return v.writeTreeIcons(icons)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从所属子树移出属性,供移动和回收站操作在原有写锁内提交。
|
||||||
|
func takeTreeIcons(icons map[string]TreeIcon, root string, directory bool) map[string]TreeIcon {
|
||||||
|
taken := map[string]TreeIcon{}
|
||||||
|
for p, icon := range icons {
|
||||||
|
if p == root || directory && strings.HasPrefix(p, root+"/") {
|
||||||
|
taken[p] = icon
|
||||||
|
delete(icons, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return taken
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回收站保留属性,但普通回收站 API 不暴露内部元数据。
|
||||||
|
type trashMetadata struct {
|
||||||
|
TrashItem
|
||||||
|
Icons map[string]TreeIcon `json:"icons,omitempty"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
package vault
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setIcon(t *testing.T, v *Vault, path string, icon *TreeIcon) {
|
||||||
|
t.Helper()
|
||||||
|
if err := v.SetTreeIcon(path, icon); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func treeIcons(t *testing.T, v *Vault) map[string]TreeIcon {
|
||||||
|
t.Helper()
|
||||||
|
entries, err := v.Tree()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result := map[string]TreeIcon{}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.Icon != nil {
|
||||||
|
result[entry.Path] = *entry.Icon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTreeIconsPersistFollowSubtreeAndTrash(t *testing.T) {
|
||||||
|
v := newVault(t)
|
||||||
|
if _, err := os.Stat(filepath.Join(v.root, metaDir, "tree-icons.json")); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatal("未修改时不应生成图标文件", err)
|
||||||
|
}
|
||||||
|
if _, err := v.Write("folder/nested/note.md", []byte("# 正文\n"), Precondition{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
folder, note := TreeIcon{"emoji", "📚"}, TreeIcon{"builtin", "task"}
|
||||||
|
setIcon(t, v, "folder", &folder)
|
||||||
|
setIcon(t, v, "folder/nested/note.md", ¬e)
|
||||||
|
body, rev, err := v.Read("folder/nested/note.md")
|
||||||
|
if err != nil || string(body) != "# 正文\n" || rev != Rev([]byte("# 正文\n")) {
|
||||||
|
t.Fatal("图标修改了正文/版本", err)
|
||||||
|
}
|
||||||
|
v, err = Open(v.root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := v.Move("folder/nested/note.md", "folder/nested/renamed.md"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := v.Move("folder", "destination/moved"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := map[string]TreeIcon{"destination/moved": folder, "destination/moved/nested/renamed.md": note}
|
||||||
|
if got := treeIcons(t, v); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("改名/移动后图标不符: %v", got)
|
||||||
|
}
|
||||||
|
item, err := v.Delete("destination/moved")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := treeIcons(t, v); len(got) != 0 {
|
||||||
|
t.Fatalf("删除后保留了属性: %v", got)
|
||||||
|
}
|
||||||
|
if _, err := v.Write("destination/moved/nested/renamed.md", []byte("新内容"), Precondition{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := treeIcons(t, v); len(got) != 0 {
|
||||||
|
t.Fatalf("同名新文件继承了属性: %v", got)
|
||||||
|
}
|
||||||
|
if _, err := v.Restore(item.ID); !errors.Is(err, ErrExists) {
|
||||||
|
t.Fatal("恢复冲突应失败", err)
|
||||||
|
}
|
||||||
|
if _, err := v.Move("destination/moved", "replacement"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := v.Restore(item.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := treeIcons(t, v); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("恢复后图标不符: %v", got)
|
||||||
|
}
|
||||||
|
setIcon(t, v, "destination/moved", nil)
|
||||||
|
delete(want, "destination/moved")
|
||||||
|
if got := treeIcons(t, v); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("恢复默认失败: %v", got)
|
||||||
|
}
|
||||||
|
// 原有无图标的回收站条目也可恢复。
|
||||||
|
item, err = v.Delete("replacement")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := v.Restore(item.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTreeIconValidationAndCorruptMetadata(t *testing.T) {
|
||||||
|
v := newVault(t)
|
||||||
|
if _, err := v.Write("a.md", []byte("body"), Precondition{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, value := range []string{"📂", "👨👩👧👦", "👩🏽💻", "🇨🇳", "1️⃣", "©️", "ℹ️", "↔️", "◼️", "‼️", "〰️", "🏴\U000e0067\U000e0062\U000e0065\U000e006e\U000e0067\U000e007f"} {
|
||||||
|
setIcon(t, v, "a.md", &TreeIcon{"emoji", value})
|
||||||
|
}
|
||||||
|
for _, icon := range []TreeIcon{{"url", "https://example.com"}, {"builtin", "__proto__"}, {"builtin", "folder note"}, {"emoji", ""}, {"emoji", "hello"}, {"emoji", "<svg>"}, {"emoji", "\u200d"}, {"emoji", strings.Repeat("📂", 17)}} {
|
||||||
|
if err := v.SetTreeIcon("a.md", &icon); !errors.Is(err, ErrInvalidIcon) {
|
||||||
|
t.Fatalf("应拒绝 %+v: %v", icon, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := v.SetTreeIcon("../a.md", nil); !errors.Is(err, ErrInvalidPath) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := v.SetTreeIcon("missing.md", nil); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(filepath.Join(v.root, "a.md"), filepath.Join(v.root, "link.md")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := v.SetTreeIcon("link.md", nil); !errors.Is(err, ErrInvalidPath) {
|
||||||
|
t.Fatal("不应设置符号链接图标", err)
|
||||||
|
}
|
||||||
|
for _, data := range []string{"{", "null", `{"../a.md":{"kind":"emoji","value":"📂"}}`, `{"a.md":{"kind":"builtin","value":"absent"}}`} {
|
||||||
|
if err := os.WriteFile(filepath.Join(v.root, metaDir, "tree-icons.json"), []byte(data), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := v.Tree(); err == nil {
|
||||||
|
t.Fatal("损坏元数据不应静默清空")
|
||||||
|
}
|
||||||
|
if _, err := v.Move("a.md", "b.md"); err == nil {
|
||||||
|
t.Fatal("元数据异常时应停止移动")
|
||||||
|
}
|
||||||
|
if _, err := v.Delete("a.md"); err == nil {
|
||||||
|
t.Fatal("元数据异常时应停止删除")
|
||||||
|
}
|
||||||
|
if _, _, err := v.Read("a.md"); err != nil {
|
||||||
|
t.Fatal("原件应保持可用", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTreeIconWriteFailureRollsBack(t *testing.T) {
|
||||||
|
if os.Geteuid() == 0 {
|
||||||
|
t.Skip("目录写权限测试需以普通用户运行;root 可绕过文件权限")
|
||||||
|
}
|
||||||
|
for _, operation := range []string{"set", "move", "delete", "restore"} {
|
||||||
|
t.Run(operation, func(t *testing.T) {
|
||||||
|
v := newVault(t)
|
||||||
|
if _, err := v.Write("a.md", []byte("[link](other.md)"), Precondition{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
legacyAsset(t, v, "a.md", "p.png", "image")
|
||||||
|
icon := TreeIcon{"emoji", "📚"}
|
||||||
|
setIcon(t, v, "a.md", &icon)
|
||||||
|
var item TrashItem
|
||||||
|
var err error
|
||||||
|
if operation == "restore" {
|
||||||
|
item, err = v.Delete("a.md")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
before, err := os.ReadFile(filepath.Join(v.root, metaDir, "tree-icons.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Chmod(filepath.Join(v.root, metaDir), 0555); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer os.Chmod(filepath.Join(v.root, metaDir), 0755)
|
||||||
|
switch operation {
|
||||||
|
case "set":
|
||||||
|
err = v.SetTreeIcon("a.md", &TreeIcon{"builtin", "folder"})
|
||||||
|
case "move":
|
||||||
|
_, err = v.Move("a.md", "new/b.md")
|
||||||
|
case "delete":
|
||||||
|
_, err = v.Delete("a.md")
|
||||||
|
case "restore":
|
||||||
|
_, err = v.Restore(item.ID)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("图标元数据写失败却返回成功")
|
||||||
|
}
|
||||||
|
after, err := os.ReadFile(filepath.Join(v.root, metaDir, "tree-icons.json"))
|
||||||
|
if err != nil || string(before) != string(after) {
|
||||||
|
t.Fatal("原有元数据被改变", err)
|
||||||
|
}
|
||||||
|
if operation == "restore" {
|
||||||
|
if _, err := v.Stat("a.md"); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatal("恢复应撤回", err)
|
||||||
|
}
|
||||||
|
metadata, err := v.readTrashMetadata(item.ID)
|
||||||
|
if err != nil || metadata.Icons["a.md"] != icon {
|
||||||
|
t.Fatal("回收站图标丢失", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
body, _, err := v.Read("a.md")
|
||||||
|
if err != nil || string(body) != "[link](other.md)" {
|
||||||
|
t.Fatal("原件或正文未回滚", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(v.root, "a.assets")); err != nil {
|
||||||
|
t.Fatal("附件未回滚", err)
|
||||||
|
}
|
||||||
|
if _, err := v.Stat("new/b.md"); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatal("移动目标未撤回", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+84
-13
@@ -54,6 +54,7 @@ type Entry struct {
|
|||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
ModTime int64 `json:"mtime"` // 毫秒
|
ModTime int64 `json:"mtime"` // 毫秒
|
||||||
Info fs.FileInfo `json:"-"`
|
Info fs.FileInfo `json:"-"`
|
||||||
|
Icon *TreeIcon `json:"icon,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TrashItem struct {
|
type TrashItem struct {
|
||||||
@@ -306,12 +307,19 @@ func (v *Vault) Tree() ([]Entry, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
icons, err := v.readTreeIcons()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
visible := []Entry{}
|
visible := []Entry{}
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if e.Dir {
|
if e.Dir {
|
||||||
e.Size = 0
|
e.Size = 0
|
||||||
}
|
}
|
||||||
if e.Dir || IsImage(e.Path) || (IsNote(e.Path) && !strings.Contains(e.Path, AssetsExt+"/")) {
|
if e.Dir || IsImage(e.Path) || (IsNote(e.Path) && !strings.Contains(e.Path, AssetsExt+"/")) {
|
||||||
|
if icon, ok := icons[e.Path]; ok {
|
||||||
|
e.Icon = &icon
|
||||||
|
}
|
||||||
visible = append(visible, e)
|
visible = append(visible, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -396,6 +404,15 @@ func (v *Vault) Move(from, to string) (rewritten bool, err error) {
|
|||||||
if _, err := os.Lstat(dst); err == nil {
|
if _, err := os.Lstat(dst); err == nil {
|
||||||
return false, ErrExists
|
return false, ErrExists
|
||||||
}
|
}
|
||||||
|
icons, err := v.readTreeIcons()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
movedIcons := takeTreeIcons(icons, from, info.IsDir())
|
||||||
|
iconsChanged := len(movedIcons) > 0
|
||||||
|
for p, icon := range movedIcons {
|
||||||
|
icons[movedPath(p, from, to, info.IsDir())] = icon
|
||||||
|
}
|
||||||
srcAssets, dstAssets := "", ""
|
srcAssets, dstAssets := "", ""
|
||||||
if isNote {
|
if isNote {
|
||||||
if st, err := os.Stat(AssetsDir(src)); err == nil && st.IsDir() {
|
if st, err := os.Stat(AssetsDir(src)); err == nil && st.IsDir() {
|
||||||
@@ -405,6 +422,13 @@ func (v *Vault) Move(from, to string) (rewritten bool, err error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if srcAssets != "" {
|
||||||
|
attached := takeTreeIcons(icons, AssetsDir(from), true)
|
||||||
|
iconsChanged = iconsChanged || len(attached) > 0
|
||||||
|
for p, icon := range attached {
|
||||||
|
icons[movedPath(p, AssetsDir(from), AssetsDir(to), true)] = icon
|
||||||
|
}
|
||||||
|
}
|
||||||
var images []imageTransfer
|
var images []imageTransfer
|
||||||
var imageTargets map[string]string
|
var imageTargets map[string]string
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -486,6 +510,9 @@ func (v *Vault) Move(from, to string) (rewritten bool, err error) {
|
|||||||
images[i].removed = true
|
images[i].removed = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if iconsChanged {
|
||||||
|
err = v.writeTreeIcons(icons)
|
||||||
|
}
|
||||||
return rewritten, err
|
return rewritten, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,16 +558,32 @@ func (v *Vault) deleteLocked(rel string) (item TrashItem, err error) {
|
|||||||
item.Assets = filepath.Base(AssetsDir(p))
|
item.Assets = filepath.Base(AssetsDir(p))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
icons, err := v.readTreeIcons()
|
||||||
|
if err != nil {
|
||||||
|
return TrashItem{}, err
|
||||||
|
}
|
||||||
|
metadata := trashMetadata{TrashItem: item, Icons: takeTreeIcons(icons, rel, item.Dir)}
|
||||||
|
if item.Assets != "" {
|
||||||
|
for p, icon := range takeTreeIcons(icons, AssetsDir(rel), true) {
|
||||||
|
metadata.Icons[p] = icon
|
||||||
|
}
|
||||||
|
}
|
||||||
dir := filepath.Join(v.trash, item.ID)
|
dir := filepath.Join(v.trash, item.ID)
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
return TrashItem{}, err
|
return TrashItem{}, err
|
||||||
}
|
}
|
||||||
noteMoved := false
|
noteMoved, assetsMoved := false, false
|
||||||
defer func() {
|
defer func() {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rollbackFailed := false
|
rollbackFailed := false
|
||||||
|
if assetsMoved {
|
||||||
|
if rollback := os.Rename(filepath.Join(dir, item.Assets), AssetsDir(p)); rollback != nil {
|
||||||
|
err = errors.Join(err, fmt.Errorf("回滚回收站图片失败: %w", rollback))
|
||||||
|
rollbackFailed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
if noteMoved {
|
if noteMoved {
|
||||||
if rollback := os.Rename(filepath.Join(dir, filepath.Base(p)), p); rollback != nil {
|
if rollback := os.Rename(filepath.Join(dir, filepath.Base(p)), p); rollback != nil {
|
||||||
err = errors.Join(err, fmt.Errorf("回滚回收站笔记失败: %w", rollback))
|
err = errors.Join(err, fmt.Errorf("回滚回收站笔记失败: %w", rollback))
|
||||||
@@ -554,7 +597,7 @@ func (v *Vault) deleteLocked(rel string) (item TrashItem, err error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
meta, _ := json.Marshal(item)
|
meta, _ := json.Marshal(metadata)
|
||||||
if err = atomicWrite(filepath.Join(dir, "meta.json"), meta); err != nil {
|
if err = atomicWrite(filepath.Join(dir, "meta.json"), meta); err != nil {
|
||||||
return item, err
|
return item, err
|
||||||
}
|
}
|
||||||
@@ -567,25 +610,39 @@ func (v *Vault) deleteLocked(rel string) (item TrashItem, err error) {
|
|||||||
return item, fmt.Errorf("移动图片目录到回收站失败: %w", err)
|
return item, fmt.Errorf("移动图片目录到回收站失败: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return item, nil
|
assetsMoved = item.Assets != ""
|
||||||
|
if len(metadata.Icons) > 0 {
|
||||||
|
err = v.writeTreeIcons(icons)
|
||||||
|
}
|
||||||
|
return item, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *Vault) readTrashItem(id string) (TrashItem, error) {
|
func (v *Vault) readTrashItem(id string) (TrashItem, error) {
|
||||||
|
metadata, err := v.readTrashMetadata(id)
|
||||||
|
return metadata.TrashItem, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Vault) readTrashMetadata(id string) (trashMetadata, error) {
|
||||||
if !trashIDPattern.MatchString(id) {
|
if !trashIDPattern.MatchString(id) {
|
||||||
return TrashItem{}, ErrInvalidPath
|
return trashMetadata{}, ErrInvalidPath
|
||||||
}
|
}
|
||||||
data, err := os.ReadFile(filepath.Join(v.trash, id, "meta.json"))
|
data, err := os.ReadFile(filepath.Join(v.trash, id, "meta.json"))
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
return TrashItem{}, ErrNotFound
|
return trashMetadata{}, ErrNotFound
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return TrashItem{}, err
|
return trashMetadata{}, err
|
||||||
}
|
}
|
||||||
var item TrashItem
|
var metadata trashMetadata
|
||||||
if err := json.Unmarshal(data, &item); err != nil {
|
if err := json.Unmarshal(data, &metadata); err != nil {
|
||||||
return TrashItem{}, fmt.Errorf("回收站条目 %s 元数据损坏: %w", id, err)
|
return trashMetadata{}, fmt.Errorf("回收站条目 %s 元数据损坏: %w", id, err)
|
||||||
}
|
}
|
||||||
return item, nil
|
for p, icon := range metadata.Icons {
|
||||||
|
if _, err := CleanPath(p); err != nil || !icon.valid() || !(p == metadata.Path || metadata.Dir && strings.HasPrefix(p, metadata.Path+"/") || metadata.Assets != "" && (p == AssetsDir(metadata.Path) || strings.HasPrefix(p, AssetsDir(metadata.Path)+"/"))) {
|
||||||
|
return trashMetadata{}, fmt.Errorf("回收站条目 %s 图标元数据损坏", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return metadata, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *Vault) TrashList() ([]TrashItem, error) {
|
func (v *Vault) TrashList() ([]TrashItem, error) {
|
||||||
@@ -609,7 +666,8 @@ func (v *Vault) TrashList() ([]TrashItem, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (v *Vault) Restore(id string) (item TrashItem, err error) {
|
func (v *Vault) Restore(id string) (item TrashItem, err error) {
|
||||||
item, err = v.readTrashItem(id)
|
metadata, err := v.readTrashMetadata(id)
|
||||||
|
item = metadata.TrashItem
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return TrashItem{}, err
|
return TrashItem{}, err
|
||||||
}
|
}
|
||||||
@@ -619,6 +677,13 @@ func (v *Vault) Restore(id string) (item TrashItem, err error) {
|
|||||||
}
|
}
|
||||||
v.mu.Lock()
|
v.mu.Lock()
|
||||||
defer v.mu.Unlock()
|
defer v.mu.Unlock()
|
||||||
|
icons, err := v.readTreeIcons()
|
||||||
|
if err != nil {
|
||||||
|
return TrashItem{}, err
|
||||||
|
}
|
||||||
|
for p, icon := range metadata.Icons {
|
||||||
|
icons[p] = icon
|
||||||
|
}
|
||||||
if _, err := os.Lstat(dst); err == nil {
|
if _, err := os.Lstat(dst); err == nil {
|
||||||
return TrashItem{}, ErrExists
|
return TrashItem{}, ErrExists
|
||||||
}
|
}
|
||||||
@@ -645,7 +710,7 @@ func (v *Vault) Restore(id string) (item TrashItem, err error) {
|
|||||||
err = errors.Join(err, rollback)
|
err = errors.Join(err, rollback)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
meta, _ := json.Marshal(item)
|
meta, _ := json.Marshal(metadata)
|
||||||
if rollback := atomicWrite(filepath.Join(dir, "meta.json"), meta); rollback != nil {
|
if rollback := atomicWrite(filepath.Join(dir, "meta.json"), meta); rollback != nil {
|
||||||
err = errors.Join(err, rollback)
|
err = errors.Join(err, rollback)
|
||||||
}
|
}
|
||||||
@@ -666,7 +731,13 @@ func (v *Vault) Restore(id string) (item TrashItem, err error) {
|
|||||||
assetsMoved = true
|
assetsMoved = true
|
||||||
}
|
}
|
||||||
cleaning = true
|
cleaning = true
|
||||||
return item, os.RemoveAll(dir)
|
if err = os.RemoveAll(dir); err != nil {
|
||||||
|
return item, err
|
||||||
|
}
|
||||||
|
if len(metadata.Icons) > 0 {
|
||||||
|
err = v.writeTreeIcons(icons)
|
||||||
|
}
|
||||||
|
return item, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *Vault) Purge(id string) error {
|
func (v *Vault) Purge(id string) error {
|
||||||
|
|||||||
+3
-44
@@ -1,47 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// 线性图标(24×24 视框,描边绘制)
|
import { iconPaths, type IconName } from './icons'
|
||||||
const paths = {
|
export type { IconName } from './icons'
|
||||||
plus: 'M12 5v14M5 12h14',
|
|
||||||
eye: 'M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z',
|
|
||||||
expand: 'M8 3H3v5M16 3h5v5M21 16v5h-5M8 21H3v-5',
|
|
||||||
graph: 'M12 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM5 22a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19 22a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM10 8l-4 8M14 8l4 8',
|
|
||||||
calendar: 'M4 5h16v16H4zM8 2v6M16 2v6M4 11h16',
|
|
||||||
tag: 'M3 3h8l10 10-8 8L3 11zM7 7h.01',
|
|
||||||
copy: 'M9 9h12v12H9zM5 15H3V3h12v2',
|
|
||||||
task: 'M3 3h7v7H3zM5 6l1 1 2-2M14 6h7M3 14h7v7H3zM14 18h7',
|
|
||||||
quote: 'M10 11H4V5h6v8a6 6 0 0 1-6 6M20 11h-6V5h6v8a6 6 0 0 1-6 6',
|
|
||||||
undo: 'm8 4-5 5 5 5M3 9h10a6 6 0 0 1 0 12h-2',
|
|
||||||
redo: 'm16 4 5 5-5 5M21 9H11a6 6 0 0 0 0 12h2',
|
|
||||||
print: 'M6 9V3h12v6M6 17H3V9h18v8h-3M6 14h12v7H6z',
|
|
||||||
grid: 'M3 3h7v7H3zM14 3h7v7h-7zM3 14h7v7H3zM14 14h7v7h-7z',
|
|
||||||
|
|
||||||
menu: 'M4 6h16M4 12h16M4 18h16',
|
|
||||||
noteAdd: 'M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9zM14 3v6h6M12 12v6M9 15h6',
|
|
||||||
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',
|
|
||||||
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',
|
|
||||||
chevron: 'm9 6 6 6-6 6',
|
|
||||||
more: 'M12 6h.01M12 12h.01M12 18h.01',
|
|
||||||
close: 'M18 6 6 18M6 6l12 12',
|
|
||||||
pin: 'M16 9V3H8v6l-3 3v2h6v7h2v-7h6v-2z',
|
|
||||||
trash: 'M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6',
|
|
||||||
logout: 'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9',
|
|
||||||
restore: 'M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5',
|
|
||||||
edit: 'M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z',
|
|
||||||
search: 'M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM21 21l-4.35-4.35',
|
|
||||||
link: 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71',
|
|
||||||
key: 'M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.78 7.78 5.5 5.5 0 0 1 7.78-7.78zM15.5 7.5l3 3L22 7l-3-3',
|
|
||||||
settings: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z',
|
|
||||||
palette: 'M12 22a10 10 0 1 1 10-10c0 2.8-2.2 4-4 4h-2a2 2 0 0 0-1.5 3.3A1.7 1.7 0 0 1 12 22zM7.5 10.5h.01M12 7.5h.01M16.5 10.5h.01',
|
|
||||||
user: 'M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z',
|
|
||||||
outline: 'M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01',
|
|
||||||
source: 'm8 6-6 6 6 6m8-12 6 6-6 6',
|
|
||||||
command: 'M18 3a3 3 0 0 0-3 3v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 0 0 0-6z',
|
|
||||||
info: 'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM12 11v6M12 7h.01',
|
|
||||||
}
|
|
||||||
export type IconName = keyof typeof paths
|
|
||||||
defineProps<{ name: IconName; size?: number }>()
|
defineProps<{ name: IconName; size?: number }>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -58,6 +17,6 @@ defineProps<{ name: IconName; size?: number }>()
|
|||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path :d="paths[name]" />
|
<path :d="iconPaths[name]" />
|
||||||
</svg>
|
</svg>
|
||||||
</template>
|
</template>
|
||||||
@@ -3,8 +3,9 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
|||||||
import Icon from './Icon.vue'
|
import Icon from './Icon.vue'
|
||||||
import { basename } from './paths'
|
import { basename } from './paths'
|
||||||
import { commandTitle } from './commands'
|
import { commandTitle } from './commands'
|
||||||
import { dropDirectory, tree } from './tree'
|
import { dropDirectory, tree, treeIconTarget } from './tree'
|
||||||
import TreeNode from './TreeNode.vue'
|
import TreeNode from './TreeNode.vue'
|
||||||
|
import TreeIconPicker from './TreeIconPicker.vue'
|
||||||
import SearchPanel from './SearchPanel.vue'
|
import SearchPanel from './SearchPanel.vue'
|
||||||
import { createFolder, createNote, move, openSearch, ws } from './workspace'
|
import { createFolder, createNote, move, openSearch, ws } from './workspace'
|
||||||
import { sidebarViews } from '../plugins/registry'
|
import { sidebarViews } from '../plugins/registry'
|
||||||
@@ -89,6 +90,7 @@ onUnmounted(() => {
|
|||||||
window.removeEventListener('blur', clearDrop)
|
window.removeEventListener('blur', clearDrop)
|
||||||
window.removeEventListener('keydown', onDragKeydown)
|
window.removeEventListener('keydown', onDragKeydown)
|
||||||
clearDrop()
|
clearDrop()
|
||||||
|
treeIconTarget.value = null
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -96,8 +98,8 @@ onUnmounted(() => {
|
|||||||
<div class="rm-sidebar-inner">
|
<div class="rm-sidebar-inner">
|
||||||
<div class="rm-vault-heading">
|
<div class="rm-vault-heading">
|
||||||
<button class="rm-icon-btn" title="折叠目录" aria-label="折叠目录" @click="ws.sidebarCollapsed = true; ws.sidebarOpen = false"><Icon name="menu" /></button>
|
<button class="rm-icon-btn" title="折叠目录" aria-label="折叠目录" @click="ws.sidebarCollapsed = true; ws.sidebarOpen = false"><Icon name="menu" /></button>
|
||||||
</div>
|
|
||||||
<button class="rm-tree-label" title="文件目录操作" aria-haspopup="true" :aria-expanded="rootMenu !== null" @click="rootContext" @contextmenu="rootContext">文件目录</button>
|
<button class="rm-tree-label" title="文件目录操作" aria-haspopup="true" :aria-expanded="rootMenu !== null" @click="rootContext" @contextmenu="rootContext">文件目录</button>
|
||||||
|
</div>
|
||||||
<nav v-if="sidebarViews.size" class="rm-plugin-tabs" aria-label="侧栏视图">
|
<nav v-if="sidebarViews.size" class="rm-plugin-tabs" aria-label="侧栏视图">
|
||||||
<button class="rm-btn" :class="{ on: !currentView }" @click="selectedView = ''">文件</button>
|
<button class="rm-btn" :class="{ on: !currentView }" @click="selectedView = ''">文件</button>
|
||||||
<button v-for="[id, pluginView] in sidebarViews" :key="id" class="rm-btn" :class="{ on: selectedView === id }" @click="selectedView = id">{{ pluginView.title }}</button>
|
<button v-for="[id, pluginView] in sidebarViews" :key="id" class="rm-btn" :class="{ on: selectedView === id }" @click="selectedView = id">{{ pluginView.title }}</button>
|
||||||
@@ -121,6 +123,7 @@ onUnmounted(() => {
|
|||||||
<button @click="runRootMenu('images')"><Icon name="image" />图片管理</button>
|
<button @click="runRootMenu('images')"><Icon name="image" />图片管理</button>
|
||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
|
<TreeIconPicker v-if="treeIconTarget" :key="treeIconTarget.path" :target="treeIconTarget" />
|
||||||
<div class="rm-sidebar-foot">
|
<div class="rm-sidebar-foot">
|
||||||
<button class="rm-foot-btn" :title="commandTitle('app.settings')" @click="ws.settings = 'index'"><Icon name="settings" />设置</button>
|
<button class="rm-foot-btn" :title="commandTitle('app.settings')" @click="ws.settings = 'index'"><Icon name="settings" />设置</button>
|
||||||
<button class="rm-foot-btn" @click="emit('logout')"><Icon name="logout" />退出</button>
|
<button class="rm-foot-btn" @click="emit('logout')"><Icon name="logout" />退出</button>
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
import type { Gemoji } from 'gemoji'
|
||||||
|
import Icon from './Icon.vue'
|
||||||
|
import { iconPaths, type IconName } from './icons'
|
||||||
|
import type { TreeIcon } from './api'
|
||||||
|
import { emojiCategories, emojiLabel, iconLabels, matchesEmoji } from './treeIconCatalog'
|
||||||
|
import { basename } from './paths'
|
||||||
|
import { treeIconTarget } from './tree'
|
||||||
|
import { saveTreeIcon } from './workspace'
|
||||||
|
|
||||||
|
const props = defineProps<{ target: { path: string; anchor: HTMLElement } }>()
|
||||||
|
const panel = ref<HTMLElement>()
|
||||||
|
const search = ref<HTMLInputElement>()
|
||||||
|
const position = ref({ left: 8, top: 8 })
|
||||||
|
const tab = ref<'emoji' | 'builtin'>('emoji')
|
||||||
|
const category = ref('Objects')
|
||||||
|
const query = ref('')
|
||||||
|
const emojis = ref<Gemoji[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const loadError = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const busy = ref(false)
|
||||||
|
const recent = ref<TreeIcon[]>([])
|
||||||
|
let resizeObserver: ResizeObserver | undefined
|
||||||
|
const RECENT_KEY = 'rawmark.recent-tree-icons'
|
||||||
|
const iconNames = Object.keys(iconPaths) as IconName[]
|
||||||
|
const filtered = computed(() => {
|
||||||
|
const q = query.value.trim().toLowerCase()
|
||||||
|
return emojis.value.filter(e => q ? matchesEmoji(e, q) : e.category === category.value)
|
||||||
|
})
|
||||||
|
const linear = computed(() => {
|
||||||
|
const q = query.value.trim().toLowerCase()
|
||||||
|
return iconNames.filter(name => (name + ' ' + iconLabels[name]).toLowerCase().includes(q))
|
||||||
|
})
|
||||||
|
const visibleRecent = computed(() => recent.value.filter(icon => icon.kind === tab.value))
|
||||||
|
const chosen = (icon: TreeIcon) => `${icon.kind}:${icon.value}`
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (busy.value) return
|
||||||
|
treeIconTarget.value = null
|
||||||
|
props.target.anchor.focus({ preventScroll: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function place() {
|
||||||
|
if (!panel.value) return
|
||||||
|
const anchor = props.target.anchor.getBoundingClientRect()
|
||||||
|
const { width, height } = panel.value.getBoundingClientRect()
|
||||||
|
position.value = {
|
||||||
|
left: Math.max(8, Math.min(anchor.left, innerWidth - width - 8)),
|
||||||
|
top: Math.max(8, Math.min(anchor.bottom + 4, innerHeight - height - 8)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close() }
|
||||||
|
if (event.key !== 'Tab' || !panel.value) return
|
||||||
|
const buttons = [...panel.value.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled)')]
|
||||||
|
const index = buttons.indexOf(document.activeElement as HTMLElement)
|
||||||
|
if (event.shiftKey && index <= 0) { event.preventDefault(); buttons.at(-1)?.focus() }
|
||||||
|
else if (!event.shiftKey && index === buttons.length - 1) { event.preventDefault(); buttons[0]?.focus() }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEmojis() {
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = ''
|
||||||
|
try { emojis.value = (await import('gemoji')).gemoji }
|
||||||
|
catch (err) { loadError.value = '表情加载失败:' + (err instanceof Error ? err.message : String(err)) }
|
||||||
|
finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(icon: TreeIcon | null) {
|
||||||
|
if (busy.value) return
|
||||||
|
busy.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
await saveTreeIcon(props.target.path, icon)
|
||||||
|
if (icon) {
|
||||||
|
recent.value = [icon, ...recent.value.filter(item => chosen(item) !== chosen(icon))].slice(0, 24)
|
||||||
|
try { localStorage.setItem(RECENT_KEY, JSON.stringify(recent.value)) }
|
||||||
|
catch (err) { console.warn('保存最近图标失败', err) }
|
||||||
|
}
|
||||||
|
busy.value = false
|
||||||
|
close()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = '图标保存失败:' + (err instanceof Error ? err.message : String(err))
|
||||||
|
} finally { busy.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
try {
|
||||||
|
const data: unknown = JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]')
|
||||||
|
if (Array.isArray(data)) recent.value = data.filter((item): item is TreeIcon => item && typeof item === 'object' && typeof item.value === 'string' && (item.kind === 'emoji' || item.kind === 'builtin' && iconNames.includes(item.value))).slice(0, 24)
|
||||||
|
} catch (err) { console.warn('读取最近图标失败', err) }
|
||||||
|
place()
|
||||||
|
search.value?.focus({ preventScroll: true })
|
||||||
|
resizeObserver = new ResizeObserver(place)
|
||||||
|
resizeObserver.observe(panel.value!)
|
||||||
|
window.addEventListener('resize', place)
|
||||||
|
document.addEventListener('keydown', onKeydown, true)
|
||||||
|
void loadEmojis()
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
window.removeEventListener('resize', place)
|
||||||
|
document.removeEventListener('keydown', onKeydown, true)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div class="rm-menu-backdrop rm-tree-icon-backdrop" @click="close" @contextmenu.prevent="close" />
|
||||||
|
<section ref="panel" class="rm-tree-icon-picker" role="dialog" aria-modal="true" aria-label="修改图标" :aria-busy="busy" :style="{ left: position.left + 'px', top: position.top + 'px' }">
|
||||||
|
<header><span class="rm-tree-icon-title" :title="target.path">{{ basename(target.path) }}</span><button class="rm-icon-btn" title="关闭图标选择" aria-label="关闭图标选择" :disabled="busy" @click="close"><Icon name="close" /></button></header>
|
||||||
|
<div class="rm-tree-icon-tabs" role="tablist" aria-label="图标类型">
|
||||||
|
<button role="tab" :aria-selected="tab === 'emoji'" :disabled="busy" @click="tab = 'emoji'">表情</button>
|
||||||
|
<button role="tab" :aria-selected="tab === 'builtin'" :disabled="busy" @click="tab = 'builtin'">线性图标</button>
|
||||||
|
</div>
|
||||||
|
<input ref="search" v-model="query" class="rm-input" placeholder="搜索图标(中文 / English)" aria-label="搜索图标" :disabled="busy" />
|
||||||
|
<nav v-if="tab === 'emoji'" class="rm-tree-icon-categories" aria-label="表情分类">
|
||||||
|
<button v-for="item in emojiCategories" :key="item.id" class="rm-emoji" :title="item.label" :aria-label="item.label" :aria-pressed="category === item.id && !query" :disabled="busy" @click="category = item.id; query = ''">{{ item.icon }}</button>
|
||||||
|
</nav>
|
||||||
|
<div class="rm-tree-icon-results">
|
||||||
|
<template v-if="!query && visibleRecent.length">
|
||||||
|
<p>最近使用</p>
|
||||||
|
<div class="rm-tree-icon-grid">
|
||||||
|
<button v-for="icon in visibleRecent" :key="chosen(icon)" :class="{ 'rm-emoji': icon.kind === 'emoji' }" :title="icon.kind === 'builtin' ? iconLabels[icon.value] : icon.value" :aria-label="'最近:' + (icon.kind === 'builtin' ? iconLabels[icon.value] : icon.value)" :disabled="busy" @click="save(icon)">
|
||||||
|
<Icon v-if="icon.kind === 'builtin'" :name="icon.value" :size="20" /><template v-else>{{ icon.value }}</template>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-if="tab === 'emoji'">
|
||||||
|
<p v-if="loading">正在加载表情…</p>
|
||||||
|
<div v-else-if="loadError" role="alert">{{ loadError }} <button class="rm-btn" @click="loadEmojis">重试</button></div>
|
||||||
|
<template v-else>
|
||||||
|
<p>{{ query ? '搜索结果' : emojiCategories.find(c => c.id === category)?.label }}</p>
|
||||||
|
<div class="rm-tree-icon-grid">
|
||||||
|
<button v-for="emoji in filtered" :key="emoji.emoji" class="rm-emoji" :title="emojiLabel(emoji)" :aria-label="emojiLabel(emoji)" :disabled="busy" @click="save({ kind: 'emoji', value: emoji.emoji })">{{ emoji.emoji }}</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="!filtered.length">没有找到图标,试试其他关键词</p>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<p>线性图标</p>
|
||||||
|
<div class="rm-tree-icon-grid">
|
||||||
|
<button v-for="name in linear" :key="name" :title="iconLabels[name]" :aria-label="iconLabels[name]" :disabled="busy" @click="save({ kind: 'builtin', value: name })"><Icon :name="name" :size="20" /></button>
|
||||||
|
</div>
|
||||||
|
<p v-if="!linear.length">没有找到图标,试试其他关键词</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<p v-if="error" class="rm-tree-icon-error" role="alert">{{ error }}</p>
|
||||||
|
<footer><button class="rm-btn" :disabled="busy" @click="save(null)">{{ busy ? '正在保存…' : '恢复默认图标' }}</button></footer>
|
||||||
|
</section>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rm-tree-icon-backdrop { z-index: 46; }
|
||||||
|
.rm-tree-icon-picker { position: fixed; z-index: 47; box-sizing: border-box; display: flex; flex-direction: column; gap: var(--rm-space-2); width: 360px; max-width: calc(100vw - 16px); max-height: calc(100dvh - 16px); padding: var(--rm-space-3); border: 1px solid var(--rm-divider); border-radius: 14px; background: color-mix(in srgb, var(--rm-bg) 96%, transparent); backdrop-filter: blur(24px); box-shadow: var(--rm-shadow); font-size: var(--rm-ui-font-size); }
|
||||||
|
header { flex: none; display: flex; align-items: center; gap: var(--rm-space-2); }
|
||||||
|
.rm-tree-icon-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.rm-tree-icon-tabs { display: flex; border-bottom: 1px solid var(--rm-divider); }
|
||||||
|
.rm-tree-icon-tabs button { flex: 1; min-height: var(--rm-control-size); border: 0; border-bottom: 2px solid transparent; background: none; color: var(--rm-text-muted); cursor: pointer; }
|
||||||
|
.rm-tree-icon-tabs [aria-selected="true"] { color: var(--rm-text); border-bottom-color: var(--rm-accent); }
|
||||||
|
.rm-tree-icon-categories { display: flex; flex-wrap: wrap; gap: 2px; }
|
||||||
|
.rm-tree-icon-categories button { flex: 1; min-width: 28px; min-height: var(--rm-control-size); border: 0; border-radius: 4px; background: none; font-size: 18px; cursor: pointer; }
|
||||||
|
.rm-tree-icon-results { flex: 1 1 auto; min-height: 0; max-height: 300px; overflow: auto; overscroll-behavior: contain; }
|
||||||
|
.rm-tree-icon-results p { margin: var(--rm-space-2) 0; color: var(--rm-text-muted); font-size: var(--rm-ui-font-small); }
|
||||||
|
.rm-tree-icon-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(32px, 1fr)); gap: 3px; }
|
||||||
|
.rm-tree-icon-grid button { display: flex; align-items: center; justify-content: center; min-width: 0; height: 36px; padding: 0; border: 0; border-radius: 4px; background: none; color: var(--rm-text); font-size: 22px; cursor: pointer; }
|
||||||
|
.rm-tree-icon-grid button:hover, .rm-tree-icon-categories button:hover, .rm-tree-icon-categories [aria-pressed="true"] { background: var(--rm-hover); }
|
||||||
|
.rm-tree-icon-error { margin: 0; max-height: 80px; overflow: auto; color: var(--rm-danger); overflow-wrap: anywhere; }
|
||||||
|
footer { flex: none; display: flex; justify-content: flex-end; }
|
||||||
|
@media (pointer: coarse) { .rm-tree-icon-grid { grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); } .rm-tree-icon-grid button, .rm-tree-icon-categories button, .rm-tree-icon-tabs button { min-height: 44px; } }
|
||||||
|
</style>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
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, dirname, inAssetDir, isImage, joinPath } from './paths'
|
import { basename, dirname, inAssetDir, isImage, joinPath } from './paths'
|
||||||
import { contextSelection, dropDirectory, expanded, selectedPaths, selectPath, type TreeNode } from './tree'
|
import { contextSelection, dropDirectory, expanded, selectedPaths, selectPath, treeIconTarget, type TreeNode } from './tree'
|
||||||
import { batchFiles, exportFiles } from './batch'
|
import { batchFiles, exportFiles } from './batch'
|
||||||
import { createFolder, createNote, move, openNote, renameTarget, ws } from './workspace'
|
import { createFolder, createNote, move, openNote, renameTarget, ws } from './workspace'
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ function commitRename() {
|
|||||||
if (target && target !== props.node.path) void move(props.node.path, target)
|
if (target && target !== props.node.path) void move(props.node.path, target)
|
||||||
}
|
}
|
||||||
|
|
||||||
function runMenu(action: 'note' | 'folder' | 'import' | 'images' | 'rename' | 'move' | 'delete' | 'export') {
|
function runMenu(action: 'note' | 'folder' | 'import' | 'images' | 'icon' | 'rename' | 'move' | 'delete' | 'export') {
|
||||||
menuOpen.value = false
|
menuOpen.value = false
|
||||||
const path = props.node.path
|
const path = props.node.path
|
||||||
if (action === 'note' || action === 'folder') {
|
if (action === 'note' || action === 'folder') {
|
||||||
@@ -86,12 +86,18 @@ function runMenu(action: 'note' | 'folder' | 'import' | 'images' | 'rename' | 'm
|
|||||||
void (action === 'note' ? createNote(path) : createFolder(path))
|
void (action === 'note' ? createNote(path) : createFolder(path))
|
||||||
} else if (action === 'import') ws.importing = path
|
} else if (action === 'import') ws.importing = path
|
||||||
else if (action === 'images') { ws.imagesAll = false; ws.images = path }
|
else if (action === 'images') { ws.imagesAll = false; ws.images = path }
|
||||||
|
else if (action === 'icon') editIcon()
|
||||||
else if (action === 'rename') ws.renaming = path
|
else if (action === 'rename') ws.renaming = path
|
||||||
else if (action === 'move') ws.moving = [...selectedPaths]
|
else if (action === 'move') ws.moving = [...selectedPaths]
|
||||||
else if (action === 'export') void exportFiles([...selectedPaths])
|
else if (action === 'export') void exportFiles([...selectedPaths])
|
||||||
else void batchFiles('delete', [...selectedPaths])
|
else void batchFiles('delete', [...selectedPaths])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function editIcon(event?: MouseEvent) {
|
||||||
|
const anchor = event?.currentTarget instanceof HTMLElement ? event.currentTarget : row.value?.querySelector<HTMLElement>('.rm-tree-icon')
|
||||||
|
if (anchor) treeIconTarget.value = { path: props.node.path, anchor }
|
||||||
|
}
|
||||||
|
|
||||||
function showMenu() {
|
function showMenu() {
|
||||||
contextSelection(props.node.path)
|
contextSelection(props.node.path)
|
||||||
menuOpen.value = true
|
menuOpen.value = true
|
||||||
@@ -152,6 +158,7 @@ function onDrop(e: DragEvent) {
|
|||||||
:draggable="!isRenaming && !assetDir"
|
:draggable="!isRenaming && !assetDir"
|
||||||
role="treeitem"
|
role="treeitem"
|
||||||
:aria-selected="selectedPaths.has(node.path)"
|
:aria-selected="selectedPaths.has(node.path)"
|
||||||
|
:aria-expanded="node.dir ? isOpen : undefined"
|
||||||
@click="activate"
|
@click="activate"
|
||||||
@contextmenu.prevent.stop="showMenu"
|
@contextmenu.prevent.stop="showMenu"
|
||||||
@touchstart.passive="onTouchStart"
|
@touchstart.passive="onTouchStart"
|
||||||
@@ -163,6 +170,12 @@ function onDrop(e: DragEvent) {
|
|||||||
@drop="onDrop"
|
@drop="onDrop"
|
||||||
>
|
>
|
||||||
<span class="rm-tree-chevron" :class="{ open: isOpen, hidden: !node.dir }"><Icon name="chevron" :size="16" /></span>
|
<span class="rm-tree-chevron" :class="{ open: isOpen, hidden: !node.dir }"><Icon name="chevron" :size="16" /></span>
|
||||||
|
<button v-if="!isRenaming" type="button" class="rm-tree-icon" :class="{ 'rm-emoji': node.icon?.kind === 'emoji' || (!node.icon && !image) }" :aria-label="'修改图标:' + node.name" title="修改图标" @click.stop="editIcon" @touchstart.stop @contextmenu.prevent.stop="editIcon">
|
||||||
|
<Icon v-if="node.icon?.kind === 'builtin'" :name="node.icon.value" :size="18" />
|
||||||
|
<template v-else-if="node.icon?.kind === 'emoji'">{{ node.icon.value }}</template>
|
||||||
|
<Icon v-else-if="image" name="image" :size="18" />
|
||||||
|
<template v-else>{{ node.dir ? '📂' : '📄' }}</template>
|
||||||
|
</button>
|
||||||
<input
|
<input
|
||||||
v-if="isRenaming"
|
v-if="isRenaming"
|
||||||
ref="input"
|
ref="input"
|
||||||
@@ -173,7 +186,6 @@ function onDrop(e: DragEvent) {
|
|||||||
@keydown.esc.prevent="cancelRename"
|
@keydown.esc.prevent="cancelRename"
|
||||||
@blur="commitRename"
|
@blur="commitRename"
|
||||||
/>
|
/>
|
||||||
<Icon v-if="image" name="image" :size="18" />
|
|
||||||
<span v-if="!isRenaming" class="rm-tree-name">{{ node.dir ? node.name : node.name.replace(/\.md$/, '') }}</span>
|
<span v-if="!isRenaming" class="rm-tree-name">{{ node.dir ? node.name : node.name.replace(/\.md$/, '') }}</span>
|
||||||
<button v-if="!isRenaming" class="rm-icon-btn rm-tree-more" title="更多操作" @click.stop="menuOpen ? (menuOpen = false) : showMenu()">
|
<button v-if="!isRenaming" class="rm-icon-btn rm-tree-more" title="更多操作" @click.stop="menuOpen ? (menuOpen = false) : showMenu()">
|
||||||
<Icon name="more" />
|
<Icon name="more" />
|
||||||
@@ -189,15 +201,20 @@ function onDrop(e: DragEvent) {
|
|||||||
<button @click="runMenu('import')"><Icon name="import" />导入</button>
|
<button @click="runMenu('import')"><Icon name="import" />导入</button>
|
||||||
</template>
|
</template>
|
||||||
<button v-if="image && selectedPaths.size === 1" @click="menuOpen = false; ws.image = node.path"><Icon name="image" />预览 / 替换图片</button>
|
<button v-if="image && selectedPaths.size === 1" @click="menuOpen = false; ws.image = node.path"><Icon name="image" />预览 / 替换图片</button>
|
||||||
|
<button v-if="selectedPaths.size === 1" @click="runMenu('icon')"><Icon name="palette" />修改图标</button>
|
||||||
<button v-if="!assetDir && selectedPaths.size === 1" @click="runMenu('rename')"><Icon name="edit" />重命名</button>
|
<button v-if="!assetDir && selectedPaths.size === 1" @click="runMenu('rename')"><Icon name="edit" />重命名</button>
|
||||||
<button v-if="!assetDir" @click="runMenu('move')"><Icon name="folder" />移动到…</button>
|
<button v-if="!assetDir" @click="runMenu('move')"><Icon name="folder" />移动到…</button>
|
||||||
<button @click="runMenu('export')"><Icon name="note" />导出 Markdown</button>
|
<button @click="runMenu('export')"><Icon name="note" />导出 Markdown</button>
|
||||||
<button v-if="!assetDir" class="danger" @click="runMenu('delete')"><Icon name="trash" />删除</button>
|
<button v-if="!assetDir" class="danger" @click="runMenu('delete')"><Icon name="trash" />删除</button>
|
||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
<ul v-if="node.dir && isOpen" class="rm-tree-list">
|
<Transition name="rm-tree-expand" @before-leave="($el: Element) => $el.setAttribute('inert', '')" @leave-cancelled="($el: Element) => $el.removeAttribute('inert')">
|
||||||
|
<div v-if="node.dir && isOpen" class="rm-tree-children">
|
||||||
|
<ul class="rm-tree-list">
|
||||||
<TreeNode v-for="child in node.children" :key="child.path" :node="child" :depth="depth + 1" />
|
<TreeNode v-for="child in node.children" :key="child.path" :node="child" :depth="depth + 1" />
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
|
import type { IconName } from './icons'
|
||||||
|
|
||||||
// REST API 客户端,与服务端 internal/api 对应
|
// REST API 客户端,与服务端 internal/api 对应
|
||||||
|
|
||||||
|
export type TreeIcon = { kind: 'emoji'; value: string } | { kind: 'builtin'; value: IconName }
|
||||||
|
|
||||||
export interface Entry {
|
export interface Entry {
|
||||||
path: string
|
path: string
|
||||||
dir: boolean
|
dir: boolean
|
||||||
size: number
|
size: number
|
||||||
mtime: number
|
mtime: number
|
||||||
|
icon?: TreeIcon
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImportResult {
|
export interface ImportResult {
|
||||||
@@ -144,6 +149,7 @@ export const api = {
|
|||||||
exportFiles: (paths: string[]) => request('POST', '/api/v1/export', { json: { paths } }),
|
exportFiles: (paths: string[]) => request('POST', '/api/v1/export', { json: { paths } }),
|
||||||
|
|
||||||
metadata: () => json<{ notes: NoteMetadata[]; ready: boolean }>(request('GET', '/api/v1/metadata')),
|
metadata: () => json<{ notes: NoteMetadata[]; ready: boolean }>(request('GET', '/api/v1/metadata')),
|
||||||
|
setTreeIcon: (path: string, icon: TreeIcon | null) => request('PUT', '/api/v1/tree-icon', { json: { path, icon } }),
|
||||||
tree: async () => (await json<{ entries: Entry[] }>(request('GET', '/api/v1/tree'))).entries,
|
tree: async () => (await json<{ entries: Entry[] }>(request('GET', '/api/v1/tree'))).entries,
|
||||||
search: async (q: string) => (await json<{ results: SearchResult[] }>(request('GET', '/api/v1/search?q=' + encodeURIComponent(q)))).results,
|
search: async (q: string) => (await json<{ results: SearchResult[] }>(request('GET', '/api/v1/search?q=' + encodeURIComponent(q)))).results,
|
||||||
|
|
||||||
|
|||||||
+13
-2
@@ -156,7 +156,7 @@ button {
|
|||||||
.rm-tree-row { -webkit-touch-callout: none; }
|
.rm-tree-row { -webkit-touch-callout: none; }
|
||||||
.rm-tree-row.active { --rm-tree-row-overlay: var(--rm-active); background: var(--rm-active); color: var(--rm-text); font-weight: 500; }
|
.rm-tree-row.active { --rm-tree-row-overlay: var(--rm-active); background: var(--rm-active); color: var(--rm-text); font-weight: 500; }
|
||||||
.rm-tree-row.drop { outline: 2px solid var(--rm-accent); outline-offset: -2px; }
|
.rm-tree-row.drop { outline: 2px solid var(--rm-accent); outline-offset: -2px; }
|
||||||
.rm-tree-chevron { display: inline-flex; flex: none; width: var(--rm-ui-font-size); color: var(--rm-text-muted); transition: transform 0.15s; }
|
.rm-tree-chevron { display: inline-flex; flex: none; width: var(--rm-ui-font-size); color: var(--rm-text-muted); transition: transform var(--rm-panel-duration) var(--rm-panel-easing); }
|
||||||
.rm-tree-chevron.open { transform: rotate(90deg); }
|
.rm-tree-chevron.open { transform: rotate(90deg); }
|
||||||
.rm-tree-chevron.hidden { visibility: hidden; }
|
.rm-tree-chevron.hidden { visibility: hidden; }
|
||||||
.rm-tree-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.rm-tree-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
@@ -631,7 +631,7 @@ button {
|
|||||||
|
|
||||||
/* 轻玻璃工作台 */
|
/* 轻玻璃工作台 */
|
||||||
.rm-vault-heading { display: flex; align-items: center; padding: 2px 4px; }
|
.rm-vault-heading { display: flex; align-items: center; padding: 2px 4px; }
|
||||||
.rm-tree-label { flex: none; min-height: var(--rm-control-compact); padding: var(--rm-space-1) var(--rm-space-3); border: 0; background: none; font-size: var(--rm-ui-font-small); font-weight: 500; color: var(--rm-text-muted); text-align: left; cursor: pointer; }
|
.rm-tree-label { margin-left: auto; flex: none; min-height: var(--rm-control-compact); padding: var(--rm-space-1) var(--rm-space-3); border: 0; background: none; font-size: var(--rm-ui-font-small); font-weight: 500; color: var(--rm-text-muted); text-align: right; cursor: pointer; }
|
||||||
.rm-tree-label:hover { background: var(--rm-hover); }
|
.rm-tree-label:hover { background: var(--rm-hover); }
|
||||||
.rm-tree { padding: 0 var(--rm-space-2) var(--rm-space-2); }
|
.rm-tree { padding: 0 var(--rm-space-2) var(--rm-space-2); }
|
||||||
.rm-tree-row { height: var(--rm-control-compact); font-size: inherit; gap: var(--rm-space-1); }
|
.rm-tree-row { height: var(--rm-control-compact); font-size: inherit; gap: var(--rm-space-1); }
|
||||||
@@ -713,3 +713,14 @@ button {
|
|||||||
@media (max-width: 767px), (pointer: coarse) {
|
@media (max-width: 767px), (pointer: coarse) {
|
||||||
input:not([type=checkbox],[type=radio],[type=range]), select, textarea { font-size: max(16px, var(--rm-ui-font-size)); }
|
input:not([type=checkbox],[type=radio],[type=range]), select, textarea { font-size: max(16px, var(--rm-ui-font-size)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 文件树子层按实际内容高度展开;退出期间不能继续点击或聚焦。 */
|
||||||
|
.rm-tree-children { display: grid; grid-template-rows: 1fr; }
|
||||||
|
.rm-tree-children > .rm-tree-list { min-height: 0; overflow: hidden; }
|
||||||
|
.rm-tree-expand-enter-active, .rm-tree-expand-leave-active { transition: grid-template-rows var(--rm-panel-duration) var(--rm-panel-easing), opacity var(--rm-panel-duration) var(--rm-panel-easing); }
|
||||||
|
.rm-tree-expand-enter-from, .rm-tree-expand-leave-to { grid-template-rows: 0fr; opacity: 0; }
|
||||||
|
.rm-tree-expand-leave-active { pointer-events: none; }
|
||||||
|
.rm-emoji { font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif; font-weight: normal; font-style: normal; }
|
||||||
|
.rm-tree-icon { display: inline-flex; flex: none; align-items: center; justify-content: center; width: 20px; height: 24px; padding: 0; border: 0; border-radius: 4px; background: none; color: inherit; font-size: 17px; line-height: 1; cursor: pointer; }
|
||||||
|
.rm-tree-icon:hover { background: var(--rm-hover); }
|
||||||
|
@media (pointer: coarse) { .rm-tree-icon { width: 28px; height: 44px; } }
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// 线性图标(24×24 视框,描边绘制)
|
||||||
|
export const iconPaths = {
|
||||||
|
plus: 'M12 5v14M5 12h14',
|
||||||
|
eye: 'M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z',
|
||||||
|
expand: 'M8 3H3v5M16 3h5v5M21 16v5h-5M8 21H3v-5',
|
||||||
|
graph: 'M12 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM5 22a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19 22a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM10 8l-4 8M14 8l4 8',
|
||||||
|
calendar: 'M4 5h16v16H4zM8 2v6M16 2v6M4 11h16',
|
||||||
|
tag: 'M3 3h8l10 10-8 8L3 11zM7 7h.01',
|
||||||
|
copy: 'M9 9h12v12H9zM5 15H3V3h12v2',
|
||||||
|
task: 'M3 3h7v7H3zM5 6l1 1 2-2M14 6h7M3 14h7v7H3zM14 18h7',
|
||||||
|
quote: 'M10 11H4V5h6v8a6 6 0 0 1-6 6M20 11h-6V5h6v8a6 6 0 0 1-6 6',
|
||||||
|
undo: 'm8 4-5 5 5 5M3 9h10a6 6 0 0 1 0 12h-2',
|
||||||
|
redo: 'm16 4 5 5-5 5M21 9H11a6 6 0 0 0 0 12h2',
|
||||||
|
print: 'M6 9V3h12v6M6 17H3V9h18v8h-3M6 14h12v7H6z',
|
||||||
|
grid: 'M3 3h7v7H3zM14 3h7v7h-7zM3 14h7v7H3zM14 14h7v7h-7z',
|
||||||
|
|
||||||
|
menu: 'M4 6h16M4 12h16M4 18h16',
|
||||||
|
noteAdd: 'M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9zM14 3v6h6M12 12v6M9 15h6',
|
||||||
|
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',
|
||||||
|
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',
|
||||||
|
chevron: 'm9 6 6 6-6 6',
|
||||||
|
more: 'M12 6h.01M12 12h.01M12 18h.01',
|
||||||
|
close: 'M18 6 6 18M6 6l12 12',
|
||||||
|
pin: 'M16 9V3H8v6l-3 3v2h6v7h2v-7h6v-2z',
|
||||||
|
trash: 'M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6',
|
||||||
|
logout: 'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9',
|
||||||
|
restore: 'M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5',
|
||||||
|
edit: 'M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z',
|
||||||
|
search: 'M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM21 21l-4.35-4.35',
|
||||||
|
link: 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71',
|
||||||
|
key: 'M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.78 7.78 5.5 5.5 0 0 1 7.78-7.78zM15.5 7.5l3 3L22 7l-3-3',
|
||||||
|
settings: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z',
|
||||||
|
palette: 'M12 22a10 10 0 1 1 10-10c0 2.8-2.2 4-4 4h-2a2 2 0 0 0-1.5 3.3A1.7 1.7 0 0 1 12 22zM7.5 10.5h.01M12 7.5h.01M16.5 10.5h.01',
|
||||||
|
user: 'M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z',
|
||||||
|
outline: 'M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01',
|
||||||
|
source: 'm8 6-6 6 6 6m8-12 6 6-6 6',
|
||||||
|
command: 'M18 3a3 3 0 0 0-3 3v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 0 0 0-6z',
|
||||||
|
info: 'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM12 11v6M12 7h.01',
|
||||||
|
}
|
||||||
|
export type IconName = keyof typeof iconPaths
|
||||||
+6
-3
@@ -1,5 +1,5 @@
|
|||||||
import { computed, reactive, ref, watch } from 'vue'
|
import { computed, reactive, ref, shallowRef, watch } from 'vue'
|
||||||
import type { Entry } from './api'
|
import type { Entry, TreeIcon } from './api'
|
||||||
import { basename, dirname, hiddenAssetPath } from './paths'
|
import { basename, dirname, hiddenAssetPath } from './paths'
|
||||||
import { rangePaths } from './selection'
|
import { rangePaths } from './selection'
|
||||||
import { ws } from './workspace'
|
import { ws } from './workspace'
|
||||||
@@ -8,6 +8,7 @@ export interface TreeNode {
|
|||||||
name: string
|
name: string
|
||||||
path: string
|
path: string
|
||||||
dir: boolean
|
dir: boolean
|
||||||
|
icon?: TreeIcon
|
||||||
children: TreeNode[]
|
children: TreeNode[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ function build(entries: Entry[]): TreeNode[] {
|
|||||||
const dirs = new Map<string, TreeNode>()
|
const dirs = new Map<string, TreeNode>()
|
||||||
// 先按路径排序,保证父目录先于子项出现
|
// 先按路径排序,保证父目录先于子项出现
|
||||||
for (const e of [...entries].sort((a, b) => a.path.length - b.path.length)) {
|
for (const e of [...entries].sort((a, b) => a.path.length - b.path.length)) {
|
||||||
const node: TreeNode = { name: basename(e.path), path: e.path, dir: e.dir, children: [] }
|
const node: TreeNode = { name: basename(e.path), path: e.path, dir: e.dir, icon: e.icon, children: [] }
|
||||||
if (e.dir) dirs.set(e.path, node)
|
if (e.dir) dirs.set(e.path, node)
|
||||||
;(dirs.get(dirname(e.path))?.children ?? root).push(node)
|
;(dirs.get(dirname(e.path))?.children ?? root).push(node)
|
||||||
}
|
}
|
||||||
@@ -32,6 +33,8 @@ function build(entries: Entry[]): TreeNode[] {
|
|||||||
|
|
||||||
export const tree = computed(() => build(ws.entries.filter((entry) => !hiddenAssetPath(entry.path))))
|
export const tree = computed(() => build(ws.entries.filter((entry) => !hiddenAssetPath(entry.path))))
|
||||||
|
|
||||||
|
export const treeIconTarget = shallowRef<{ path: string; anchor: HTMLElement } | null>(null)
|
||||||
|
|
||||||
// null 表示无接收目标,空字符串表示根目录;文件行指向它的直接父目录。
|
// null 表示无接收目标,空字符串表示根目录;文件行指向它的直接父目录。
|
||||||
export const dropDirectory = ref<string | null>(null)
|
export const dropDirectory = ref<string | null>(null)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { Gemoji } from 'gemoji'
|
||||||
|
import type { IconName } from './icons'
|
||||||
|
|
||||||
|
export const iconLabels: Record<IconName, string> = {
|
||||||
|
plus: '加号', eye: '查看', expand: '展开', graph: '关系', calendar: '日历', tag: '标签', copy: '复制',
|
||||||
|
task: '任务', quote: '引用', undo: '撤销', redo: '重做', print: '打印', grid: '网格', menu: '菜单',
|
||||||
|
noteAdd: '新建笔记', import: '导入', folderAdd: '新建文件夹', folder: '文件夹', image: '图片', note: '文档',
|
||||||
|
chevron: '箭头', more: '更多', close: '关闭', pin: '固定', trash: '回收站', logout: '退出', restore: '恢复',
|
||||||
|
edit: '编辑', search: '搜索', link: '链接', key: '钥匙', settings: '设置', palette: '调色板', user: '用户',
|
||||||
|
outline: '大纲', source: '代码', command: '命令', info: '信息',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emojiCategories = [
|
||||||
|
{ id: 'Smileys & Emotion', label: '表情', icon: '😀' },
|
||||||
|
{ id: 'People & Body', label: '人物', icon: '👋' },
|
||||||
|
{ id: 'Animals & Nature', label: '自然', icon: '🌿' },
|
||||||
|
{ id: 'Food & Drink', label: '食物', icon: '🍎' },
|
||||||
|
{ id: 'Travel & Places', label: '旅行', icon: '🚀' },
|
||||||
|
{ id: 'Activities', label: '活动', icon: '⚽' },
|
||||||
|
{ id: 'Objects', label: '物品', icon: '💡' },
|
||||||
|
{ id: 'Symbols', label: '符号', icon: '❤️' },
|
||||||
|
{ id: 'Flags', label: '旗帜', icon: '🏳️' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 常见目录用途的中文检索词;其余仍可按 Emoji 和原有英文名称搜索。
|
||||||
|
const keywords: Record<string, string> = {
|
||||||
|
'📂': '文件夹 目录 打开', '📁': '文件夹 目录', '📄': '文档 笔记 文章', '📝': '笔记 编辑 记录',
|
||||||
|
'📃': '文档 文章', '📋': '清单 剪贴板', '📚': '书籍 知识 学习', '📖': '阅读 书籍', '📕': '书籍 红书',
|
||||||
|
'📗': '书籍 绿书', '📘': '书籍 蓝书', '📙': '书籍 橙书', '📓': '笔记本', '📒': '账本 笔记本',
|
||||||
|
'📔': '笔记本 日记', '📅': '日历 日期', '🗓️': '日历 计划', '✅': '完成 任务 对勾', '☑️': '勾选 任务',
|
||||||
|
'⭐': '星星 收藏', '🌟': '星星 重要', '❤️': '爱心 喜欢', '🔥': '火 热门', '💡': '灯泡 灵感 想法',
|
||||||
|
'📌': '固定 图钉', '🔗': '链接', '🔒': '锁 安全', '🔑': '钥匙 密钥', '⚙️': '设置 齿轮',
|
||||||
|
'🛠️': '工具 开发', '💻': '电脑 编程 代码', '🖥️': '电脑 显示器', '📱': '手机', '🖼️': '图片 相册',
|
||||||
|
'🎨': '艺术 设计 调色板', '🎵': '音乐', '🎬': '电影 视频', '📷': '相机 摄影', '🎮': '游戏',
|
||||||
|
'🏠': '房子 主页 家', '🏡': '家 房子', '🚀': '火箭 项目 发布', '💼': '工作 公文包', '🎯': '目标',
|
||||||
|
'🌍': '地球 世界', '🌏': '地球 亚洲', '🌱': '幼苗 成长', '🌿': '植物 叶子 自然', '🌸': '花 樱花',
|
||||||
|
'☀️': '太阳 晴天', '🌙': '月亮 夜晚', '🐱': '猫 动物', '🐶': '狗 动物', '🍎': '苹果 食物',
|
||||||
|
'☕': '咖啡 饮料', '😀': '笑脸 开心 表情', '😊': '微笑 开心 表情', '🤔': '思考 想法', '👍': '赞 好',
|
||||||
|
'⚠️': '警告 注意', '❗': '感叹号 重要', '❓': '问号 问题', '💰': '钱 财务', '🎓': '毕业 学习',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emojiLabel(emoji: Gemoji): string {
|
||||||
|
return keywords[emoji.emoji] ?? emoji.description
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesEmoji(emoji: Gemoji, query: string): boolean {
|
||||||
|
return [emoji.emoji, emoji.description, ...emoji.names, ...emoji.tags, keywords[emoji.emoji] ?? '', emojiCategories.find(c => c.id === emoji.category)?.label ?? ''].join(' ').toLowerCase().includes(query)
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { createApp, nextTick, type App } from 'vue'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import Sidebar from './Sidebar.vue'
|
||||||
|
import { expanded, selectedPaths, treeIconTarget } from './tree'
|
||||||
|
import * as workspace from './workspace'
|
||||||
|
import { api } from './api'
|
||||||
|
|
||||||
|
let app: App
|
||||||
|
let host: HTMLElement
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.spyOn(workspace, 'saveTreeIcon').mockResolvedValue()
|
||||||
|
vi.spyOn(workspace, 'openNote').mockResolvedValue()
|
||||||
|
vi.spyOn(workspace, 'move').mockResolvedValue({ ok: true })
|
||||||
|
localStorage.removeItem('rawmark.recent-tree-icons')
|
||||||
|
Object.assign(workspace.ws, { searchOpen: false, active: '', image: null, renaming: null, entries: [
|
||||||
|
{ path: 'folder', dir: true }, { path: 'folder/a.md', dir: false },
|
||||||
|
{ path: 'folder/b.md', dir: false }, { path: 'folder/c.md', dir: false },
|
||||||
|
].map(e => ({ ...e, size: 0, mtime: 0 })) })
|
||||||
|
expanded.clear()
|
||||||
|
selectedPaths.clear()
|
||||||
|
treeIconTarget.value = null
|
||||||
|
host = document.createElement('div')
|
||||||
|
document.body.append(host)
|
||||||
|
app = createApp(Sidebar)
|
||||||
|
app.mount(host)
|
||||||
|
await nextTick()
|
||||||
|
})
|
||||||
|
afterEach(() => { app.unmount(); host.remove(); vi.restoreAllMocks() })
|
||||||
|
const row = (name: string) => [...host.querySelectorAll<HTMLElement>('.rm-tree-row')].find(e => e.querySelector('.rm-tree-name')?.textContent === name)!
|
||||||
|
const click = async (element: Element, init: MouseEventInit = {}) => { element.dispatchEvent(new MouseEvent('click', { bubbles: true, ...init })); await nextTick() }
|
||||||
|
const button = (name: string) => document.querySelector<HTMLElement>(`[aria-label="${name}"]`)!
|
||||||
|
async function picker() {
|
||||||
|
await click(row('folder').querySelector('.rm-tree-icon')!)
|
||||||
|
await vi.waitFor(() => expect(button('搜索图标')).not.toBeNull())
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('文件树图标操作', () => {
|
||||||
|
it('保留右侧目录菜单,点击默认图标不折叠目录或改变多选', async () => {
|
||||||
|
expect(host.querySelector('.rm-vault-heading .rm-tree-label')?.textContent).toBe('文件目录')
|
||||||
|
expect(row('folder').querySelector('.rm-tree-icon')?.textContent?.trim()).toBe('📂')
|
||||||
|
await click(row('folder'))
|
||||||
|
expect(row('a').querySelector('.rm-tree-icon')?.textContent?.trim()).toBe('📄')
|
||||||
|
await click(row('a'), { ctrlKey: true })
|
||||||
|
const selected = [...selectedPaths]
|
||||||
|
await picker()
|
||||||
|
expect(expanded.has('folder')).toBe(true)
|
||||||
|
expect([...selectedPaths]).toEqual(selected)
|
||||||
|
expect(workspace.openNote).not.toHaveBeenCalled()
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||||
|
await nextTick()
|
||||||
|
expect(treeIconTarget.value).toBeNull()
|
||||||
|
expect(document.activeElement).toBe(row('folder').querySelector('.rm-tree-icon'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('中文搜索表情并保存到当前节点,最近使用只记成功操作', async () => {
|
||||||
|
await picker()
|
||||||
|
const input = button('搜索图标') as HTMLInputElement
|
||||||
|
input.value = '文件夹'
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
|
await vi.waitFor(() => expect(button('文件夹 目录 打开')).not.toBeNull())
|
||||||
|
await click(button('文件夹 目录 打开'))
|
||||||
|
await vi.waitFor(() => expect(treeIconTarget.value).toBeNull())
|
||||||
|
expect(workspace.saveTreeIcon).toHaveBeenCalledExactlyOnceWith('folder', { kind: 'emoji', value: '📂' })
|
||||||
|
expect(JSON.parse(localStorage.getItem('rawmark.recent-tree-icons')!)).toEqual([{ kind: 'emoji', value: '📂' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('保存失败保留弹窗与明确错误,恢复默认成功后关闭', async () => {
|
||||||
|
vi.mocked(workspace.saveTreeIcon).mockRejectedValueOnce(new Error('磁盘只读'))
|
||||||
|
await picker()
|
||||||
|
await click([...document.querySelectorAll('button')].find(e => e.textContent === '恢复默认图标')!)
|
||||||
|
await vi.waitFor(() => expect(document.querySelector('[role="alert"]')?.textContent).toContain('磁盘只读'))
|
||||||
|
expect(treeIconTarget.value?.path).toBe('folder')
|
||||||
|
expect(localStorage.getItem('rawmark.recent-tree-icons')).toBeNull()
|
||||||
|
await click([...document.querySelectorAll('button')].find(e => e.textContent === '恢复默认图标')!)
|
||||||
|
await vi.waitFor(() => expect(treeIconTarget.value).toBeNull())
|
||||||
|
expect(workspace.saveTreeIcon).toHaveBeenLastCalledWith('folder', null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('线性图标可搜索,右键入口选择同一节点', async () => {
|
||||||
|
row('folder').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))
|
||||||
|
await nextTick()
|
||||||
|
await click([...document.querySelectorAll('.rm-tree-menu button')].find(e => e.textContent === '修改图标')!)
|
||||||
|
await click([...document.querySelectorAll('[role="tab"]')].find(e => e.textContent === '线性图标')!)
|
||||||
|
const input = button('搜索图标') as HTMLInputElement
|
||||||
|
input.value = '任务'; input.dispatchEvent(new Event('input', { bubbles: true })); await nextTick()
|
||||||
|
expect(document.querySelectorAll('.rm-tree-icon-grid button')).toHaveLength(1)
|
||||||
|
await click(button('任务'))
|
||||||
|
expect(workspace.saveTreeIcon).toHaveBeenCalledWith('folder', { kind: 'builtin', value: 'task' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Ctrl 和 Shift 仍按可见行选择,快速展开/收起保留最新状态', async () => {
|
||||||
|
await click(row('folder'))
|
||||||
|
await click(row('a'))
|
||||||
|
await click(row('c'), { shiftKey: true })
|
||||||
|
expect([...selectedPaths]).toEqual(['folder/a.md', 'folder/b.md', 'folder/c.md'])
|
||||||
|
await click(row('b'), { ctrlKey: true })
|
||||||
|
expect([...selectedPaths]).toEqual(['folder/a.md', 'folder/c.md'])
|
||||||
|
await click(row('folder'))
|
||||||
|
await click(row('folder'))
|
||||||
|
await vi.waitFor(() => expect(row('c')).toBeDefined())
|
||||||
|
expect(expanded.has('folder')).toBe(true)
|
||||||
|
await click(row('folder'))
|
||||||
|
await vi.waitFor(() => expect(row('a')).toBeUndefined())
|
||||||
|
expect(selectedPaths.has('folder/a.md')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('从图标按住拖动仍使用整行的拖放契约,不能误开选择框', async () => {
|
||||||
|
const setData = vi.fn()
|
||||||
|
const event = new Event('dragstart', { bubbles: true, cancelable: true })
|
||||||
|
Object.defineProperty(event, 'dataTransfer', { value: { setData, effectAllowed: '' } })
|
||||||
|
row('folder').querySelector('.rm-tree-icon')!.dispatchEvent(event)
|
||||||
|
expect(event.defaultPrevented).toBe(false)
|
||||||
|
expect(setData).toHaveBeenCalledExactlyOnceWith('application/x-rawmark-path', 'folder')
|
||||||
|
expect(treeIconTarget.value).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('持久属性直接来自目录 API,不维护第二份图标映射', async () => {
|
||||||
|
workspace.ws.entries[0].icon = { kind: 'builtin', value: 'task' }
|
||||||
|
await nextTick()
|
||||||
|
expect(row('folder').querySelector('.rm-tree-icon svg')).not.toBeNull()
|
||||||
|
workspace.ws.entries[0].icon = { kind: 'emoji', value: '📚' }
|
||||||
|
await nextTick()
|
||||||
|
expect(row('folder').querySelector('.rm-tree-icon')?.textContent?.trim()).toBe('📚')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('图标请求沿用认证、禁缓存和 null 重置契约', async () => {
|
||||||
|
const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}'))
|
||||||
|
await api.setTreeIcon('folder/a.md', null)
|
||||||
|
expect(fetch).toHaveBeenCalledWith('/api/v1/tree-icon', expect.objectContaining({ method: 'PUT', credentials: 'same-origin', cache: 'no-store', body: '{"path":"folder/a.md","icon":null}' }))
|
||||||
|
})
|
||||||
@@ -8,7 +8,7 @@ import { editorCommands } from '../editor/shortcuts'
|
|||||||
import { refreshImages } from '../editor/livepreview'
|
import { refreshImages } from '../editor/livepreview'
|
||||||
import { createEditorState, insertImageFiles, pasteImages, languageCompartment, pluginCompartment, pluginEditorExtensions, pluginLanguage, preferencesCompartment, previewCompartment, previewExtensions } from '../editor'
|
import { createEditorState, insertImageFiles, pasteImages, languageCompartment, pluginCompartment, pluginEditorExtensions, pluginLanguage, preferencesCompartment, previewCompartment, previewExtensions } from '../editor'
|
||||||
import { handleShortcut, registerCommand } from './commands'
|
import { handleShortcut, registerCommand } from './commands'
|
||||||
import { api, ApiError, encodePath, type Entry } from './api'
|
import { api, ApiError, encodePath, type Entry, type TreeIcon } from './api'
|
||||||
import { drafts } from './drafts'
|
import { drafts } from './drafts'
|
||||||
import { refreshMetadata } from './metadata'
|
import { refreshMetadata } from './metadata'
|
||||||
import { connectEvents, type VaultEvent } from './events'
|
import { connectEvents, type VaultEvent } from './events'
|
||||||
@@ -663,6 +663,12 @@ async function withOp<T>(fn: () => Promise<T>): Promise<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function saveTreeIcon(path: string, icon: TreeIcon | null) {
|
||||||
|
await withOp(() => api.setTreeIcon(path, icon))
|
||||||
|
try { await refreshTree() }
|
||||||
|
catch (err) { toast('图标已保存,目录刷新失败:' + errorText(err)) }
|
||||||
|
}
|
||||||
|
|
||||||
export async function createNote(dir: string) {
|
export async function createNote(dir: string) {
|
||||||
const path = uniqueName(dir, '未命名', NOTE_EXT)
|
const path = uniqueName(dir, '未命名', NOTE_EXT)
|
||||||
ws.renaming = path
|
ws.renaming = path
|
||||||
|
|||||||
Reference in new issue
Block a user