-
Notifications
You must be signed in to change notification settings - Fork 41
Expand file tree
/
Copy pathfile_browser.js
More file actions
225 lines (199 loc) · 7.55 KB
/
file_browser.js
File metadata and controls
225 lines (199 loc) · 7.55 KB
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
216
217
218
219
220
221
222
223
224
225
// ## File Browser Example
//
// A simple file browser example built with
// [`regular-table`](https://github.com/finos/regular-table). Also a great
// introduction to `row_headers`, and how to use them to achieve group-like and
// tree-like behavior. For this example, we'll want the latter.
//
// ## Tree-like `row_headers`
//
// `regular-table` will merge consecutive `<th>` defined in `row_headers` with the
// same content, but it will prefer `rowspan` to `colspan`, inserting empty `<th>`
// when necessary to fill-in gaps, since `table-cell` elements cannot overlap.
// Knowing this, it is easy to fine-tune header structure and behavior with empty
// cells. In this case, we want to modify the basic _group-like_ `row_headers`
// layout to support _tree-like_ asymmetric groups. Typically, when representing
// groups of rows via `row_headers`, for example a file structure like so:
//
// - Dir_1
// - Dir_2
// - File_1
// - File_2
//
// ... one may think to implement a `regular-table` Virtual Data Model using a
// `row_headers` parameter like this:
//
// ```json
// [
// ["Dir_1"],
// ["Dir_1", "Dir_2"],
// ["Dir_1", "Dir_2", "File_1"],
// ["Dir_1", "File_2"]
// ]
// ```
//
// This will render _group-like_ row headers, with the consecutive `"Dir_1"` and
// `"Dir_2"` elements merged via `rowspan`. The resulting headers visually indicate
// all content on the right-hand side belong to the directory. This is exactly what
// column headers do, but it is not very like a file-tree; each directory "level"
// will determine its respective column's minimum width, and deeply assymmetric
// trees will yield wide row headers.
//
// Group-like row headers are nice for always keeping the entire directory path in
// view regardless of scroll position, but for a more tree-like like experience, we
// can instead replace the consecutive duplicates with `""`.
//
// ```json
// [["Dir_1"], ["", "Dir_2"], ["", "", "File_1"], ["", "File_2"]]
// ```
//
// The new consecutive `""` will still merge via `rowspan`, excluding the first
// row, but `regular-table` will detect that a `<th>` lacks a `rowspan`, and
// instead merge trailing `undefined`/empty values via `colspan` to produce one
// long `<th>` for each row header group, as in the HTML below. In this tree-like
// layout, no content will exclusively occupy any but the last column of
// `row_headers`, and these empty columns can then be sized via CSS to create trees
// of any geometry, where e.g. "directory" group rows overlap the columns of their
// children as-in a conventional file tree.
//
// Despite this long-winded explanation, the implementation in Javascript is fairly
// straightforward, and for our purposes, we only need create one such path for
// `row_headers` at a time.
import "/dist/esm/regular-table.js";
function new_path(n, name) {
return Array(n).fill("").concat([name]);
}
// ## File System
//
// We can use a regular 2D Array, row oriented, for the file system listing state
// itself, including file metadata like `size` and the open/closed state of
// directory rows.
const COLUMNS = [["size"], ["kind"], ["modified"], ["writable"]];
const DATA = Array.from(generateDirContents());
// These file-metadata rows are fake, but for the purposes of an example, they are
// worth putting "B Movie"-level effort into making look like a "real" file system.
function new_row(type) {
const scale = Math.random() > 0.5 ? "kb" : "mb";
const size = numberFormat(Math.pow(Math.random(), 2) * 1000);
const date = dateFormat(new Date());
return [`${size} ${scale}`, type, date, true];
}
// For the fake file system contents themselves, we will generate directory
// contents on the fly as directories are opened and closed by the user.
function* generateDirContents(n = 0) {
for (let i = 0; i < 5; i++) {
yield {
path: new_path(n, `Dir_${i}`),
row: new_row("directory"),
is_open: false,
};
}
for (let i = 0; i < 5; i++) {
yield {
path: new_path(n, `File_${i}`),
row: new_row("file"),
};
}
}
// Open and close directory operations are applied via `DATA.splice()`, mutating
// the `Array` reference directly and inserting or stripping elements as needed.
function closeDir(y) {
const path = DATA[y].path;
while (y + 2 < DATA.length && DATA[y + 1].path.length > path.length) {
DATA.splice(y + 1, 1);
}
}
function openDir(y) {
const new_contents = generateDirContents(DATA[y].path.length);
DATA.splice(y + 1, 0, ...Array.from(new_contents));
}
function toggleDir(y) {
const { is_open } = DATA[y];
if (is_open) {
closeDir(y);
} else {
openDir(y);
}
DATA[y].is_open = !is_open;
}
// ## Virtual Data Model
//
// `DATA` needs to be _transposed_ before we can return slices of it from our
// `dataListener()` function, because it is row-oriented and `regular-table`
// expects column-oriented data.
function transpose(m) {
return m.length === 0 ? [] : m[0].map((x, i) => m.map((x) => x[i]));
}
// Otherwise, this `dataListener()` is very similar to `2d_array.md`.
function dataListener(x0, y0, x1, y1) {
return {
num_rows: DATA.length,
num_columns: DATA[0].row.length,
row_headers: DATA.slice(y0, y1).map((z) => z.path.slice()),
column_headers: COLUMNS.slice(x0, x1),
data: transpose(DATA.slice(y0, y1).map(({ row }) => row.slice(x0, x1))),
};
}
// ## Custom Style
//
// Directory and file icon styles applied as classes, using `getMeta()`, every `td`
// is mapped back to it's row in `DATA`.
function styleListener() {
for (const td of window.regularTable.querySelectorAll("tbody th")) {
const { y, value } = window.regularTable.getMeta(td);
const { row, is_open } = DATA[y];
const [, type] = row;
td.classList.toggle("fb-directory", !!value && type === "directory");
td.classList.toggle("fb-file", !!value && type === "file");
td.classList.toggle("fb-open", !!value && is_open);
}
}
// ## UI
//
// When directory rows are clicked, generate new directory contents at the `td`
// metadata's `y` coordinate in `DATA` and redraw.
//
// TODO `resetAutoSize()` is not documented - this is currently required to
// prevent the column size scroll memoize functionality from pinning the sizes
// of the 'blank' cells, as these columns may be re-purposed as the user expands
// or collapses the tree. But auto-sizing is not well formalized feature yet
// and this API is just a stand-in.
function mousedownListener() {
if (event.target.tagName === "TH") {
const meta = regularTable.getMeta(event.target);
if (DATA[meta.y].row[1] === "directory") {
toggleDir(meta.y);
regularTable.resetAutoSize();
regularTable.draw();
}
}
}
// ## Main
regularTable.setDataListener(dataListener);
regularTable.addStyleListener(styleListener);
regularTable.addEventListener("mousedown", mousedownListener);
regularTable.addEventListener("scroll", () => {
regularTable.resetAutoSize();
});
regularTable.draw();
// ## Appendix (Utilities)
function numberFormat(x) {
const formatter = new Intl.NumberFormat("en-us", {
style: "decimal",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
return formatter.format(x);
}
function dateFormat(x) {
const formatter = new Intl.DateTimeFormat("en-us", {
week: "numeric",
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
});
return formatter.format(x);
}