@silverbulletmd/silverbullet 2.10.0 → 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/client/asset_bundle/bundle.ts +0 -1
- package/client/config.ts +343 -0
- package/client/markdown_parser/constants.ts +3 -1
- package/client/plugos/hooks/command.ts +0 -5
- package/client/plugos/hooks/event.ts +0 -6
- package/client/plugos/hooks/mq.ts +0 -3
- package/client/plugos/hooks/slash_command.ts +1 -7
- package/client/plugos/hooks/syscall.ts +0 -4
- package/client/plugos/manifest_cache.ts +0 -32
- package/client/plugos/plug.ts +0 -3
- package/client/plugos/plug_compile.ts +3 -13
- package/client/plugos/sandboxes/worker_sandbox.ts +19 -9
- package/client/plugos/syscalls/editor.ts +128 -53
- package/client/plugos/syscalls/event.ts +0 -2
- package/client/plugos/syscalls/fetch.ts +0 -4
- package/client/plugos/syscalls/icon.ts +46 -0
- package/client/plugos/syscalls/index.ts +33 -13
- package/client/plugos/syscalls/jsonschema.ts +0 -1
- package/client/plugos/syscalls/mq.ts +0 -1
- package/client/plugos/syscalls/navigator.ts +143 -0
- package/client/plugos/syscalls/search.ts +44 -0
- package/client/plugos/syscalls/shell.ts +0 -1
- package/client/plugos/syscalls/space.ts +250 -7
- package/client/plugos/syscalls/sync.ts +56 -4
- package/client/plugos/syscalls/system.ts +38 -0
- package/client/plugos/system.ts +7 -5
- package/client/plugos/worker_runtime.ts +0 -2
- package/client/space_lua/aggregates.ts +0 -16
- package/client/space_lua/ast.ts +0 -5
- package/client/space_lua/ast_narrow.ts +0 -5
- package/client/space_lua/budget.ts +113 -0
- package/client/space_lua/budget_ui.ts +49 -0
- package/client/space_lua/eval.ts +113 -88
- package/client/space_lua/labels.ts +0 -4
- package/client/space_lua/numeric.ts +0 -5
- package/client/space_lua/parse.ts +0 -17
- package/client/space_lua/quarantine.ts +127 -0
- package/client/space_lua/query_collection.ts +3 -47
- package/client/space_lua/query_env.ts +0 -2
- package/client/space_lua/render_lua_markdown.ts +2 -7
- package/client/space_lua/render_widget.ts +18 -1
- package/client/space_lua/runtime.ts +50 -70
- package/client/space_lua/stdlib/format.ts +0 -12
- package/client/space_lua/stdlib/js.ts +0 -2
- package/client/space_lua/stdlib/load.ts +0 -1
- package/client/space_lua/stdlib/math.ts +0 -7
- package/client/space_lua/stdlib/net.ts +0 -4
- package/client/space_lua/stdlib/os.ts +0 -10
- package/client/space_lua/stdlib/pattern.ts +10 -15
- package/client/space_lua/stdlib/space_lua.ts +1 -2
- package/client/space_lua/stdlib/string_pack.ts +0 -3
- package/client/space_lua/stdlib/table.ts +0 -5
- package/client/space_lua/stdlib.ts +18 -31
- package/client/space_lua/tonumber.ts +0 -9
- package/dist/plug-compile.js +2 -3
- package/package.json +13 -3
- package/plug-api/lib/async.ts +1 -6
- package/plug-api/lib/collation.ts +22 -0
- package/plug-api/lib/crypto.ts +16 -15
- package/plug-api/lib/dates.ts +33 -0
- package/plug-api/lib/fuzzy.ts +282 -0
- package/plug-api/lib/json.ts +0 -6
- package/plug-api/lib/limited_map.ts +0 -2
- package/plug-api/lib/link_write.ts +27 -0
- package/plug-api/lib/ref.ts +28 -5
- package/plug-api/lib/resolve.ts +0 -1
- package/plug-api/lib/resolve_path.ts +290 -0
- package/plug-api/lib/shortcut.ts +15 -0
- package/plug-api/lib/tags.ts +0 -3
- package/plug-api/lib/transclusion.ts +28 -1
- package/plug-api/lib/tree.ts +0 -5
- package/plug-api/lib/yaml.ts +3 -42
- package/plug-api/syscall.ts +1 -5
- package/plug-api/syscalls/config.ts +2 -2
- package/plug-api/syscalls/editor.ts +59 -213
- package/plug-api/syscalls/icon.ts +18 -0
- package/plug-api/syscalls/index.ts +22 -4
- package/plug-api/syscalls/search.ts +11 -0
- package/plug-api/syscalls/space.ts +122 -5
- package/plug-api/syscalls/sync.ts +23 -2
- package/plug-api/syscalls/system.ts +46 -0
- package/plug-api/syscalls.ts +2 -0
- package/plug-api/system_mock.ts +13 -0
- package/plug-api/types/client.ts +4 -3
- package/plug-api/types/datastore.ts +11 -2
- package/plug-api/types/index.ts +10 -0
- package/plug-api/types/profile.ts +13 -0
- package/plug-api/types/revisions.ts +67 -0
- package/plug-api/ui/description.ts +64 -0
- package/plug-api/ui/hover.ts +69 -0
- package/plug-api/ui/index.ts +58 -2
- package/plug-api/ui/scroll.ts +30 -0
- package/plug-api/ui/tree_model.ts +263 -0
- package/plug-api/ui/tree_types.ts +66 -0
- package/plug-api/ui/use_fit_collapse.ts +67 -0
- package/plugs/builtin_plugs.ts +0 -1
- package/plugs/index/types.ts +9 -0
- package/plug-api/lib/memory_cache.ts +0 -21
- /package/plug-api/{ui → lib}/panel_styles.ts +0 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `block: "nearest"` scrolling, confined to one container.
|
|
3
|
+
*
|
|
4
|
+
* scrollIntoView can cross a same-origin iframe boundary and scroll the host
|
|
5
|
+
* editor. Restricting the calculation to one container prevents that.
|
|
6
|
+
*/
|
|
7
|
+
export function revealInContainer(el: Element, container: Element) {
|
|
8
|
+
const c = container.getBoundingClientRect();
|
|
9
|
+
const e = el.getBoundingClientRect();
|
|
10
|
+
if (e.top < c.top) {
|
|
11
|
+
container.scrollTop += e.top - c.top;
|
|
12
|
+
} else if (e.bottom > c.bottom) {
|
|
13
|
+
// An element taller than the container aligns to its top rather than
|
|
14
|
+
// scrolling past it, same as `block: "nearest"`.
|
|
15
|
+
container.scrollTop += Math.min(e.bottom - c.bottom, e.top - c.top);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `revealInContainer`, with the container found by walking up from `el` via
|
|
21
|
+
* `closest(selector)` -- for a caller that doesn't already hold a reference
|
|
22
|
+
* to the scroll container.
|
|
23
|
+
*/
|
|
24
|
+
export function revealInClosest(
|
|
25
|
+
el: Element | null | undefined,
|
|
26
|
+
selector: string,
|
|
27
|
+
) {
|
|
28
|
+
const container = el?.closest(selector);
|
|
29
|
+
if (el && container) revealInContainer(el, container);
|
|
30
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import type { Row } from "./tree_types.ts";
|
|
2
|
+
|
|
3
|
+
export type TreeNode = {
|
|
4
|
+
path: string;
|
|
5
|
+
segment: string;
|
|
6
|
+
row?: Row;
|
|
7
|
+
children: TreeNode[];
|
|
8
|
+
isFolder: boolean;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type VisibleRow = { node: TreeNode; depth: number };
|
|
12
|
+
|
|
13
|
+
/** A folder reads only its own row's priority: an implicit folder, or one
|
|
14
|
+
* whose page carries no priority, stays where the alphabet put it however its
|
|
15
|
+
* children are decorated. */
|
|
16
|
+
function priorityOf(node: TreeNode): number {
|
|
17
|
+
const priority = node.row?.priority;
|
|
18
|
+
return typeof priority === "number" && Number.isFinite(priority)
|
|
19
|
+
? priority
|
|
20
|
+
: 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function buildTree(
|
|
24
|
+
rows: Row[],
|
|
25
|
+
separator: string,
|
|
26
|
+
foldersFirst: boolean,
|
|
27
|
+
): TreeNode {
|
|
28
|
+
const root: TreeNode = {
|
|
29
|
+
path: "",
|
|
30
|
+
segment: "",
|
|
31
|
+
children: [],
|
|
32
|
+
isFolder: true,
|
|
33
|
+
};
|
|
34
|
+
const byPath = new Map<string, TreeNode>([["", root]]);
|
|
35
|
+
const ensure = (path: string): TreeNode => {
|
|
36
|
+
let node = byPath.get(path);
|
|
37
|
+
if (node) return node;
|
|
38
|
+
const idx = path.lastIndexOf(separator);
|
|
39
|
+
const parent = ensure(idx === -1 ? "" : path.slice(0, idx));
|
|
40
|
+
node = {
|
|
41
|
+
path,
|
|
42
|
+
segment: idx === -1 ? path : path.slice(idx + separator.length),
|
|
43
|
+
children: [],
|
|
44
|
+
isFolder: false,
|
|
45
|
+
};
|
|
46
|
+
byPath.set(path, node);
|
|
47
|
+
parent.children.push(node);
|
|
48
|
+
parent.isFolder = true;
|
|
49
|
+
return node;
|
|
50
|
+
};
|
|
51
|
+
for (const row of rows) {
|
|
52
|
+
const path = String(row.obj.name ?? row.primary);
|
|
53
|
+
ensure(path).row = row;
|
|
54
|
+
}
|
|
55
|
+
// Stable, so everything a row says nothing about keeps the order the source
|
|
56
|
+
// put it in -- which is what makes an undecorated tree alphabetical.
|
|
57
|
+
const sortLevel = (n: TreeNode) => {
|
|
58
|
+
n.children.sort((a, b) => {
|
|
59
|
+
if (foldersFirst && a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1;
|
|
60
|
+
return priorityOf(b) - priorityOf(a);
|
|
61
|
+
});
|
|
62
|
+
n.children.forEach(sortLevel);
|
|
63
|
+
};
|
|
64
|
+
sortLevel(root);
|
|
65
|
+
return root;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function flattenVisible(
|
|
69
|
+
root: TreeNode,
|
|
70
|
+
expanded: Set<string>,
|
|
71
|
+
): VisibleRow[] {
|
|
72
|
+
const out: VisibleRow[] = [];
|
|
73
|
+
const walk = (node: TreeNode, depth: number) => {
|
|
74
|
+
for (const child of node.children) {
|
|
75
|
+
out.push({ node: child, depth });
|
|
76
|
+
if (child.isFolder && expanded.has(child.path)) walk(child, depth + 1);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
walk(root, 0);
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function pruneTree(
|
|
84
|
+
root: TreeNode,
|
|
85
|
+
scores: Map<string, number>,
|
|
86
|
+
): TreeNode {
|
|
87
|
+
// Post-order: each node's best score is computed once, from its
|
|
88
|
+
// already-scored children, rather than re-walked on every sort comparison.
|
|
89
|
+
const bestScores = new Map<TreeNode, number>();
|
|
90
|
+
const prune = (n: TreeNode): TreeNode | null => {
|
|
91
|
+
const children = n.children
|
|
92
|
+
.map(prune)
|
|
93
|
+
.filter((c): c is TreeNode => c !== null);
|
|
94
|
+
if (children.length === 0 && !scores.has(n.path)) return null;
|
|
95
|
+
const copy = { ...n, children };
|
|
96
|
+
bestScores.set(
|
|
97
|
+
copy,
|
|
98
|
+
Math.max(
|
|
99
|
+
scores.get(n.path) ?? -1,
|
|
100
|
+
...children.map((c) => bestScores.get(c)!),
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
copy.children.sort((a, b) => bestScores.get(b)! - bestScores.get(a)!);
|
|
104
|
+
return copy;
|
|
105
|
+
};
|
|
106
|
+
return prune(root) ?? { ...root, children: [] };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function ancestorPaths(path: string, separator: string): string[] {
|
|
110
|
+
const parts = path.split(separator);
|
|
111
|
+
return parts
|
|
112
|
+
.slice(0, -1)
|
|
113
|
+
.map((_, i) => parts.slice(0, i + 1).join(separator));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* `prev` with `paths` expanded. Under `expandAll` expanding is a *removal*:
|
|
118
|
+
* the set holds what the user collapsed, and everything not in it is already
|
|
119
|
+
* open. Toggling needs no such branch -- flipping membership means "collapse"
|
|
120
|
+
* under one reading and "expand" under the other, which is the same gesture.
|
|
121
|
+
*/
|
|
122
|
+
export function withExpanded(
|
|
123
|
+
prev: Set<string>,
|
|
124
|
+
paths: string[],
|
|
125
|
+
expandAll: boolean,
|
|
126
|
+
): Set<string> {
|
|
127
|
+
const next = new Set(prev);
|
|
128
|
+
for (const path of paths) {
|
|
129
|
+
if (expandAll) next.delete(path);
|
|
130
|
+
else next.add(path);
|
|
131
|
+
}
|
|
132
|
+
return next;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function allFolderPaths(node: TreeNode): Set<string> {
|
|
136
|
+
const out = new Set<string>();
|
|
137
|
+
const walk = (n: TreeNode) => {
|
|
138
|
+
for (const child of n.children) {
|
|
139
|
+
if (child.isFolder) {
|
|
140
|
+
out.add(child.path);
|
|
141
|
+
walk(child);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
walk(node);
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The object a node hands to a caller. A copy, deliberately: `isFolder` is a
|
|
151
|
+
* UI-injected hint, and stamping it onto the row's own object would leave it
|
|
152
|
+
* on the caller's cached row forever. Same shape `planMove` produces, so a
|
|
153
|
+
* folder reaches downstream code looking the same either way.
|
|
154
|
+
*/
|
|
155
|
+
export function nodeObject(node: TreeNode): Record<string, any> {
|
|
156
|
+
const obj: Record<string, any> = node.row
|
|
157
|
+
? { ...node.row.obj }
|
|
158
|
+
: { name: node.path };
|
|
159
|
+
if (node.isFolder) obj.isFolder = true;
|
|
160
|
+
return obj;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Every node under `root`, in tree order (the root itself excluded). */
|
|
164
|
+
export function allNodes(root: TreeNode): TreeNode[] {
|
|
165
|
+
const out: TreeNode[] = [];
|
|
166
|
+
const walk = (n: TreeNode) => {
|
|
167
|
+
for (const child of n.children) {
|
|
168
|
+
out.push(child);
|
|
169
|
+
walk(child);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
walk(root);
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function findNode(root: TreeNode, path: string): TreeNode | undefined {
|
|
177
|
+
if (path === root.path) return root;
|
|
178
|
+
for (const child of root.children) {
|
|
179
|
+
if (path === child.path) return child;
|
|
180
|
+
if (path.startsWith(child.path)) {
|
|
181
|
+
const found = findNode(child, path);
|
|
182
|
+
if (found) return found;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export type MovePlan =
|
|
189
|
+
| { kind: "none" }
|
|
190
|
+
| { kind: "collision"; newName: string }
|
|
191
|
+
| { kind: "move"; obj: Record<string, any>; newName: string };
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* What dropping `draggedPath` on `targetFolder` (`""` = root) amounts to: the
|
|
195
|
+
* new name, the object to hand the mover, or a reason not to move at all.
|
|
196
|
+
* Kept here, away from the DOM, because every interesting case is a
|
|
197
|
+
* path/tree question -- and the drop handler must not guess at any of them.
|
|
198
|
+
*/
|
|
199
|
+
export function planMove(
|
|
200
|
+
root: TreeNode,
|
|
201
|
+
draggedPath: string,
|
|
202
|
+
targetFolder: string,
|
|
203
|
+
separator: string,
|
|
204
|
+
): MovePlan {
|
|
205
|
+
if (!draggedPath || draggedPath === targetFolder) return { kind: "none" };
|
|
206
|
+
// Into its own subtree: the prefix rename would chase itself.
|
|
207
|
+
if (targetFolder.startsWith(draggedPath + separator)) return { kind: "none" };
|
|
208
|
+
const idx = draggedPath.lastIndexOf(separator);
|
|
209
|
+
const segment =
|
|
210
|
+
idx === -1 ? draggedPath : draggedPath.slice(idx + separator.length);
|
|
211
|
+
const newName =
|
|
212
|
+
targetFolder === "" ? segment : targetFolder + separator + segment;
|
|
213
|
+
if (newName === draggedPath) return { kind: "none" };
|
|
214
|
+
const node = findNode(root, draggedPath);
|
|
215
|
+
if (!node) return { kind: "none" };
|
|
216
|
+
// Any node, not just row-bearing ones: dropping onto a name that already
|
|
217
|
+
// exists as a folder would merge two subtrees, silently overwriting every
|
|
218
|
+
// page whose name appears in both.
|
|
219
|
+
if (findNode(root, newName)) return { kind: "collision", newName };
|
|
220
|
+
return { kind: "move", obj: nodeObject(node), newName };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export type TreeDisplay = {
|
|
224
|
+
tree: TreeNode;
|
|
225
|
+
visible: VisibleRow[];
|
|
226
|
+
effectiveExpanded: Set<string>;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Combines build + (optional) prune + auto-expand-while-filtering + flatten
|
|
231
|
+
* into the single derivation both a tree renderer and its host's keyboard
|
|
232
|
+
* math need, so they never compute two different visible-row lists.
|
|
233
|
+
*
|
|
234
|
+
* `effectiveExpanded` is the one answer to "is this folder open", whichever
|
|
235
|
+
* reading `state` has: everything downstream -- the chevrons, `ArrowLeft`,
|
|
236
|
+
* spring-loading -- asks it and never the raw set.
|
|
237
|
+
*
|
|
238
|
+
* @param state the persisted set, and how to read it: the folders the user
|
|
239
|
+
* opened, or (under `expandAll`) the ones they closed.
|
|
240
|
+
*/
|
|
241
|
+
export function computeTreeDisplay(
|
|
242
|
+
rows: Row[],
|
|
243
|
+
separator: string,
|
|
244
|
+
foldersFirst: boolean,
|
|
245
|
+
state: { expanded: Set<string>; expandAll: boolean },
|
|
246
|
+
scores?: Map<string, number>,
|
|
247
|
+
): TreeDisplay {
|
|
248
|
+
const built = buildTree(rows, separator, foldersFirst);
|
|
249
|
+
const tree = scores ? pruneTree(built, scores) : built;
|
|
250
|
+
// Filtering force-expands the pruned tree either way, so the two readings
|
|
251
|
+
// converge there and neither set is consulted -- no double bookkeeping.
|
|
252
|
+
let effectiveExpanded: Set<string>;
|
|
253
|
+
if (scores) {
|
|
254
|
+
effectiveExpanded = allFolderPaths(tree);
|
|
255
|
+
} else if (state.expandAll) {
|
|
256
|
+
effectiveExpanded = allFolderPaths(tree);
|
|
257
|
+
for (const path of state.expanded) effectiveExpanded.delete(path);
|
|
258
|
+
} else {
|
|
259
|
+
effectiveExpanded = state.expanded;
|
|
260
|
+
}
|
|
261
|
+
const visible = flattenVisible(tree, effectiveExpanded);
|
|
262
|
+
return { tree, visible, effectiveExpanded };
|
|
263
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Description } from "./description.ts";
|
|
2
|
+
|
|
3
|
+
export type Decoration = {
|
|
4
|
+
text?: string;
|
|
5
|
+
icon?: string;
|
|
6
|
+
cssClass?: string;
|
|
7
|
+
position?: "left" | "right";
|
|
8
|
+
/** Native tooltip, for a chip whose text is deliberately imprecise. */
|
|
9
|
+
title?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type Row = {
|
|
13
|
+
obj: Record<string, any>;
|
|
14
|
+
primary: string;
|
|
15
|
+
/**
|
|
16
|
+
* What a *tree* row shows in place of its last path segment. For a view
|
|
17
|
+
* whose hierarchy is synthesized rather than read off a name, the path is
|
|
18
|
+
* what nests the row and this is what it reads as.
|
|
19
|
+
*/
|
|
20
|
+
label?: string;
|
|
21
|
+
description?: Description;
|
|
22
|
+
decorations?: Decoration[];
|
|
23
|
+
/** Extra CSS classes for the row element itself. */
|
|
24
|
+
cssClass?: string;
|
|
25
|
+
/** Tree mode: floats the row above its lower-priority siblings (default 0).
|
|
26
|
+
* Only ever reorders one level; a folder takes its own row's priority. */
|
|
27
|
+
priority?: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A row action, minus whatever decides what it does and whether it applies:
|
|
32
|
+
* the renderer only ever knows what to draw and which index to hand back.
|
|
33
|
+
*/
|
|
34
|
+
export type ActionMeta = {
|
|
35
|
+
/**
|
|
36
|
+
* A Feather name (bare or `feather:`-prefixed), or literal SVG markup (a
|
|
37
|
+
* string starting with `<svg` after trimming leading whitespace) --
|
|
38
|
+
* resolved to a node by the consuming plug.
|
|
39
|
+
*/
|
|
40
|
+
icon?: string;
|
|
41
|
+
label: string;
|
|
42
|
+
/** Whether the action has a `when` predicate, i.e. needs a mask to show. */
|
|
43
|
+
hasWhen: boolean;
|
|
44
|
+
/** `"rw"` hides the action while the client is in read-only mode. */
|
|
45
|
+
requireMode?: "rw";
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* What a consumer has to say about one rendered thing: which actions apply to
|
|
50
|
+
* it, and which icon it draws with. Meant to be computed for a whole batch at
|
|
51
|
+
* once, never per hover, per keystroke, or per selection change.
|
|
52
|
+
*/
|
|
53
|
+
export type RowState = {
|
|
54
|
+
/** Per-action `when` result, parallel to the action list. */
|
|
55
|
+
actions?: boolean[];
|
|
56
|
+
/** The row's icon, as a template node to clone. */
|
|
57
|
+
icon?: Element;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Row state by rendered thing. Exactly one half is populated, per mode. */
|
|
61
|
+
export type RowStates = {
|
|
62
|
+
/** Tree mode, keyed by node path -- covers folders and page/folder duals. */
|
|
63
|
+
byPath?: Map<string, RowState>;
|
|
64
|
+
/** List mode, keyed by the row itself. */
|
|
65
|
+
byRow?: WeakMap<Row, RowState>;
|
|
66
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, useState } from "preact/hooks";
|
|
2
|
+
|
|
3
|
+
export type FitLevel = "full" | "icons";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Measures whether a row of items (buttons in `barRef`, inside `wrapRef`)
|
|
7
|
+
* fits at full width, collapsing to `"icons"` when it doesn't.
|
|
8
|
+
*
|
|
9
|
+
* The fit is measured rather than guessed at a breakpoint: how wide the bar
|
|
10
|
+
* wants to be depends on its items' own labels, which no `@container`
|
|
11
|
+
* condition can express. The measurement happens on layout, only when the
|
|
12
|
+
* wrapper actually resizes or `signature` changes -- never per frame.
|
|
13
|
+
*
|
|
14
|
+
* `signature` should change whenever the set of items (or which of them carry
|
|
15
|
+
* icons) changes, so a fresh full-width measurement is taken rather than
|
|
16
|
+
* reusing one from a different set of items.
|
|
17
|
+
*/
|
|
18
|
+
export function useFitCollapse(
|
|
19
|
+
wrapRef: { current: HTMLElement | null },
|
|
20
|
+
barRef: { current: HTMLElement | null },
|
|
21
|
+
signature: string,
|
|
22
|
+
): FitLevel {
|
|
23
|
+
const [fit, setFit] = useState<FitLevel>("full");
|
|
24
|
+
// The full-width rendering's own width, recorded while it's on screen: a
|
|
25
|
+
// collapsed bar can no longer be asked what the fuller one wanted, and this
|
|
26
|
+
// is what lets a widened wrapper snap straight back to "full" instead of
|
|
27
|
+
// staying collapsed until something else forces a re-measure.
|
|
28
|
+
const fullWidth = useRef<number | undefined>(undefined);
|
|
29
|
+
|
|
30
|
+
useLayoutEffect(() => {
|
|
31
|
+
fullWidth.current = undefined;
|
|
32
|
+
setFit("full");
|
|
33
|
+
}, [signature]);
|
|
34
|
+
|
|
35
|
+
useLayoutEffect(() => {
|
|
36
|
+
const bar = barRef.current;
|
|
37
|
+
const wrap = wrapRef.current;
|
|
38
|
+
if (!bar || !wrap) return;
|
|
39
|
+
const measure = () => {
|
|
40
|
+
// Summed rather than read off the bar: it may wrap as a last resort,
|
|
41
|
+
// and a wrapped bar reports the width it settled for, never the width
|
|
42
|
+
// it wanted.
|
|
43
|
+
const style = getComputedStyle(bar);
|
|
44
|
+
const items = [...bar.children] as HTMLElement[];
|
|
45
|
+
const gaps =
|
|
46
|
+
(parseFloat(style.columnGap) || 0) * Math.max(0, items.length - 1);
|
|
47
|
+
const padding =
|
|
48
|
+
(parseFloat(style.paddingLeft) || 0) +
|
|
49
|
+
(parseFloat(style.paddingRight) || 0);
|
|
50
|
+
const wanted =
|
|
51
|
+
items.reduce((sum, s) => sum + s.getBoundingClientRect().width, 0) +
|
|
52
|
+
gaps +
|
|
53
|
+
padding;
|
|
54
|
+
if (fit === "full") fullWidth.current = wanted;
|
|
55
|
+
|
|
56
|
+
const available = wrap.clientWidth;
|
|
57
|
+
const full = fullWidth.current;
|
|
58
|
+
setFit(full !== undefined && full > available ? "icons" : "full");
|
|
59
|
+
};
|
|
60
|
+
measure();
|
|
61
|
+
const observer = new ResizeObserver(measure);
|
|
62
|
+
observer.observe(wrap);
|
|
63
|
+
return () => observer.disconnect();
|
|
64
|
+
}, [fit, signature]);
|
|
65
|
+
|
|
66
|
+
return fit;
|
|
67
|
+
}
|
package/plugs/builtin_plugs.ts
CHANGED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type AnchorHit = {
|
|
2
|
+
page: string;
|
|
3
|
+
hostTag: string;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type ResolveAnchorResult =
|
|
7
|
+
| { ok: true; page: string; hostTag: string; range: [number, number] }
|
|
8
|
+
| { ok: false; reason: "missing" }
|
|
9
|
+
| { ok: false; reason: "duplicate"; hits: AnchorHit[] };
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { LimitedMap } from "./limited_map.ts";
|
|
2
|
-
|
|
3
|
-
const cache = new LimitedMap<any>(50);
|
|
4
|
-
|
|
5
|
-
export async function ttlCache<K, V>(
|
|
6
|
-
key: K,
|
|
7
|
-
fn: (key: K) => Promise<V>,
|
|
8
|
-
ttlSecs?: number,
|
|
9
|
-
): Promise<V> {
|
|
10
|
-
if (!ttlSecs) {
|
|
11
|
-
return fn(key);
|
|
12
|
-
}
|
|
13
|
-
const serializedKey = JSON.stringify(key);
|
|
14
|
-
const cached = cache.get(serializedKey);
|
|
15
|
-
if (cached) {
|
|
16
|
-
return cached;
|
|
17
|
-
}
|
|
18
|
-
const result = await fn(key);
|
|
19
|
-
cache.set(serializedKey, result, ttlSecs * 1000);
|
|
20
|
-
return result;
|
|
21
|
-
}
|
|
File without changes
|