Ben Schlegel
2023-09-20 b029eeadabe0877df6ec11443c68743f1494bc40
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
// @ts-ignore
import { QuartzPluginData } from "../plugins/vfile"
import { resolveRelative } from "../util/path"
 
type OrderEntries = "sort" | "filter" | "map"
 
export interface Options {
  title: string
  folderDefaultState: "collapsed" | "open"
  folderClickBehavior: "collapse" | "link"
  useSavedState: boolean
  sortFn: (a: FileNode, b: FileNode) => number
  filterFn?: (node: FileNode) => boolean
  mapFn?: (node: FileNode) => void
  order?: OrderEntries[]
}
 
type DataWrapper = {
  file: QuartzPluginData
  path: string[]
}
 
export type FolderState = {
  path: string
  collapsed: boolean
}
 
// Structure to add all files into a tree
export class FileNode {
  children: FileNode[]
  name: string
  file: QuartzPluginData | null
  depth: number
 
  constructor(name: string, file?: QuartzPluginData, depth?: number) {
    this.children = []
    this.name = name
    this.file = file ? structuredClone(file) : null
    this.depth = depth ?? 0
  }
 
  private insert(file: DataWrapper) {
    if (file.path.length === 1) {
      this.children.push(new FileNode(file.file.frontmatter!.title, file.file, this.depth + 1))
    } else {
      const next = file.path[0]
      file.path = file.path.splice(1)
      for (const child of this.children) {
        if (child.name === next) {
          child.insert(file)
          return
        }
      }
 
      const newChild = new FileNode(next, undefined, this.depth + 1)
      newChild.insert(file)
      this.children.push(newChild)
    }
  }
 
  // Add new file to tree
  add(file: QuartzPluginData, splice: number = 0) {
    this.insert({ file, path: file.filePath!.split("/").splice(splice) })
  }
 
  // Print tree structure (for debugging)
  print(depth: number = 0) {
    let folderChar = ""
    if (!this.file) folderChar = "|"
    console.log("-".repeat(depth), folderChar, this.name, this.depth)
    this.children.forEach((e) => e.print(depth + 1))
  }
 
  /**
   * Filter FileNode tree. Behaves similar to `Array.prototype.filter()`, but modifies tree in place
   * @param filterFn function to filter tree with
   */
  filter(filterFn: (node: FileNode) => boolean) {
    this.children = this.children.filter(filterFn)
    this.children.forEach((child) => child.filter(filterFn))
  }
 
  /**
   * Filter FileNode tree. Behaves similar to `Array.prototype.map()`, but modifies tree in place
   * @param mapFn function to use for mapping over tree
   */
  map(mapFn: (node: FileNode) => void) {
    mapFn(this)
 
    this.children.forEach((child) => child.map(mapFn))
  }
 
  /**
   * Get folder representation with state of tree.
   * Intended to only be called on root node before changes to the tree are made
   * @param collapsed default state of folders (collapsed by default or not)
   * @returns array containing folder state for tree
   */
  getFolderPaths(collapsed: boolean): FolderState[] {
    const folderPaths: FolderState[] = []
 
    const traverse = (node: FileNode, currentPath: string) => {
      if (!node.file) {
        const folderPath = currentPath + (currentPath ? "/" : "") + node.name
        if (folderPath !== "") {
          folderPaths.push({ path: folderPath, collapsed })
        }
        node.children.forEach((child) => traverse(child, folderPath))
      }
    }
 
    traverse(this, "")
 
    return folderPaths
  }
 
  // Sort order: folders first, then files. Sort folders and files alphabetically
  /**
   * Sorts tree according to sort/compare function
   * @param sortFn compare function used for `.sort()`, also used recursively for children
   */
  sort(sortFn: (a: FileNode, b: FileNode) => number) {
    this.children = this.children.sort(sortFn)
    this.children.forEach((e) => e.sort(sortFn))
  }
}
 
type ExplorerNodeProps = {
  node: FileNode
  opts: Options
  fileData: QuartzPluginData
  fullPath?: string
}
 
export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodeProps) {
  // Get options
  const folderBehavior = opts.folderClickBehavior
  const isDefaultOpen = opts.folderDefaultState === "open"
 
  // Calculate current folderPath
  let pathOld = fullPath ? fullPath : ""
  let folderPath = ""
  if (node.name !== "") {
    folderPath = `${pathOld}/${node.name}`
  }
 
  return (
    <li>
      {node.file ? (
        // Single file node
        <li key={node.file.slug}>
          <a href={resolveRelative(fileData.slug!, node.file.slug!)} data-for={node.file.slug}>
            {node.name}
          </a>
        </li>
      ) : (
        <div>
          {node.name !== "" && (
            // Node with entire folder
            // Render svg button + folder name, then children
            <div class="folder-container">
              <svg
                xmlns="http://www.w3.org/2000/svg"
                width="12"
                height="12"
                viewBox="5 8 14 8"
                fill="none"
                stroke="currentColor"
                stroke-width="2"
                stroke-linecap="round"
                stroke-linejoin="round"
                class="folder-icon"
              >
                <polyline points="6 9 12 15 18 9"></polyline>
              </svg>
              {/* render <a> tag if folderBehavior is "link", otherwise render <button> with collapse click event */}
              <div key={node.name} data-folderpath={folderPath}>
                {folderBehavior === "link" ? (
                  <a href={`${folderPath}`} data-for={node.name} class="folder-title">
                    {node.name}
                  </a>
                ) : (
                  <button class="folder-button">
                    <p class="folder-title">{node.name}</p>
                  </button>
                )}
              </div>
            </div>
          )}
          {/* Recursively render children of folder */}
          <div class={`folder-outer ${node.depth === 0 || isDefaultOpen ? "open" : ""}`}>
            <ul
              // Inline style for left folder paddings
              style={{
                paddingLeft: node.name !== "" ? "1.4rem" : "0",
              }}
              class="content"
              data-folderul={folderPath}
            >
              {node.children.map((childNode, i) => (
                <ExplorerNode
                  node={childNode}
                  key={i}
                  opts={opts}
                  fullPath={folderPath}
                  fileData={fileData}
                />
              ))}
            </ul>
          </div>
        </div>
      )}
    </li>
  )
}